Compare commits
210
Commits
No files matched your search
@@ -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,7 +47,22 @@ 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"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft' and m['command'] == 'lightcraft'"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD tarball layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
+143
-13
@@ -1,9 +1,12 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "LightCraft v<version>". See docs/releasing.md.
|
||||
# Release pipeline: every push to `release` builds platform artifacts (signed where credentials
|
||||
# are configured) and creates or updates the draft GitHub Release "LightCraft v<version>".
|
||||
# See docs/releasing.md.
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
|
||||
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
|
||||
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
|
||||
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
|
||||
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -35,7 +38,7 @@ jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# No `environment: release` here or on the other jobs that sign nothing: no release secrets.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
@@ -63,13 +66,19 @@ jobs:
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
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: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -87,6 +96,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 +123,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 +153,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 }}
|
||||
@@ -154,7 +176,6 @@ jobs:
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -167,12 +188,18 @@ 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
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
@@ -184,6 +211,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: |
|
||||
@@ -194,6 +224,9 @@ jobs:
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -201,17 +234,110 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
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
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
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 +347,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:
|
||||
@@ -237,7 +367,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -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: PdfCraft 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,10 @@ plan/
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/
|
||||
/craft-fonts
|
||||
# nix build / nix develop
|
||||
/result
|
||||
/result-*
|
||||
# model weights are never committed (SAM 3: docs/ai-masks.md)
|
||||
*.safetensors
|
||||
*.safetensors.part
|
||||
@@ -1,6 +1,6 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -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
|
||||
@@ -108,4 +119,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
@@ -1,6 +1,6 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -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
|
||||
@@ -108,4 +119,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
Generated
+1485
-30
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -29,6 +29,8 @@ lightcraft-merge = { path = "crates/merge" }
|
||||
lightcraft-engine = { path = "crates/engine" }
|
||||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -62,6 +64,10 @@ jxl-oxide = { version = "0.12", default-features = false }
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
|
||||
# Oniguruma C library into candle-core, and the product is pure Rust.
|
||||
candle-core = { version = "=0.9.2", default-features = false }
|
||||
candle-nn = { version = "=0.9.2", default-features = false }
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "parking_lot", "metal", "vulkan", "dx12", "wgsl"] }
|
||||
pollster = "1"
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ 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.
|
||||
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
|
||||
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
|
||||
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
|
||||
The port was modified by the LightCraft contributors (2026): translated from Python/PyTorch to
|
||||
Rust on candle and restructured; each ported file says so. Because of this, crates/segment is
|
||||
licensed under the Apache License 2.0 only (LICENSE-APACHE), not under the MIT option.
|
||||
|
||||
The SAM 3 weights are not part of LightCraft and are never distributed with it. A user downloads
|
||||
the facebook/sam3 checkpoint only when they choose to (in the app, or by hand), under Meta's SAM
|
||||
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
|
||||
licence. See docs/ai-masks.md.
|
||||
|
||||
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.
|
||||
@@ -59,6 +59,7 @@
|
||||
<a href="#feature-status">Status</a> ·
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="ROADMAP.md">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
@@ -200,7 +201,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
|
||||
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields:
|
||||
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
|
||||
import date, edit date, name, rating or size. The justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
so they stay smooth whether you have forty photos or forty thousand.
|
||||
|
||||
<table>
|
||||
@@ -233,11 +234,11 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
- **91 engine commands** and **48 UI commands.** List them all with `engine.commands`; read every slider's range,
|
||||
default and current value with `develop.controls`.
|
||||
- **MCP server.** `lightcraft-cli mcp` gives Claude (or any MCP client) ~100 tools: import, query, develop, mask,
|
||||
render (returned as an image), export. It runs headless, or attached to the running app with screenshots, clicks
|
||||
and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
- **Command registry.** `engine.commands` lists the available commands; `develop.controls` lists every slider's
|
||||
range, default and current value.
|
||||
- **MCP server.** `lightcraft-cli mcp` exposes the command registry to Claude (or any MCP client), alongside
|
||||
helpers for import, query, develop, mask, render (returned as an image) and export. It runs headless, or attached
|
||||
to the running app with screenshots, clicks and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
```sh
|
||||
cargo build --release -p lightcraft-cli
|
||||
@@ -258,8 +259,8 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
## Fast, native, private
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD
|
||||
composites and JPEG XL open today.
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today.
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
@@ -301,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
|
||||
@@ -334,9 +335,55 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
|
||||
available when it starts (at most one per CPU; 4 if memory can't be read), so an
|
||||
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
|
||||
builds with fewer. Test threads follow, capped at 4. Full-debuginfo linkers, one
|
||||
per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit
|
||||
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
|
||||
override these defaults. Ordinary development commands keep their own settings.
|
||||
|
||||
**Chinese and Japanese text** need 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 Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.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).
|
||||
|
||||
**Nix** builds the desktop app and `lightcraft-cli` (the Nix build always includes the craft-fonts input, so Japanese
|
||||
text has glyphs):
|
||||
|
||||
```sh
|
||||
nix run github:storytold/lightcraft # the desktop app
|
||||
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
|
||||
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
|
||||
```
|
||||
|
||||
In a flake configuration (NixOS, home-manager, nix-darwin):
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.lightcraft.url = "github:storytold/lightcraft";
|
||||
# optional: build against your own nixpkgs instead of the one LightCraft pins
|
||||
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# then, in a NixOS or home-manager module (where `inputs` is in scope):
|
||||
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
|
||||
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
|
||||
```
|
||||
|
||||
`nix build` installs the same desktop file, hicolor icons and AppStream metadata as the .deb/.rpm, and runs
|
||||
`cargo test --workspace` as its check phase (skip it with `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
|
||||
|
||||
**Keyboard:** <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
|
||||
@@ -356,7 +403,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
|
||||
@@ -367,6 +415,53 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
|
||||
|
||||
<br>
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `lightcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `lightcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `lightcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
<br>
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
LightCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
@@ -379,7 +474,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -420,7 +515,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 Chinese and Japanese fonts (Noto Sans CJK SC, 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
|
||||
@@ -433,3 +530,7 @@ Forks and modified versions must remove them.
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
+35
-23
@@ -23,31 +23,32 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; no localisation; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| User | Readiness | What blocks them |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
|
||||
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
|
||||
|
||||
## Where we're going
|
||||
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Raises colour quality for every non-DNG raw at once.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
|
||||
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
|
||||
@@ -56,7 +57,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
(references stay in the local `plan/`), then tune against the numbers.
|
||||
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance.
|
||||
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. Object / Describe masks now
|
||||
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People and Enhance are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
|
||||
@@ -81,12 +84,12 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; accessibility, localisation ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
|
||||
@@ -115,7 +118,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
@@ -159,9 +162,15 @@ The milestone estimates in the table above were made before work started and are
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
|
||||
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
|
||||
|
||||
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
|
||||
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
|
||||
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 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), 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.
|
||||
|
||||
@@ -170,14 +179,11 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
|
||||
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
|
||||
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Log
|
||||
@@ -189,12 +195,18 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
|
||||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
|
||||
## Japanese interface and text watermarks
|
||||
## Chinese and Japanese interfaces, 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
|
||||
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese 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
|
||||
|
||||
+153
-31
@@ -9,6 +9,7 @@
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -84,6 +85,12 @@ USAGE:
|
||||
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
|
||||
lightcraft-cli commands [--json] list every command id with its parameters
|
||||
lightcraft-cli controls [--json] list every develop control id with its range
|
||||
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
|
||||
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
|
||||
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
|
||||
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
|
||||
take their colour from the profile and only their tone from their own JPEG.
|
||||
lightcraft-cli --version | --help
|
||||
";
|
||||
|
||||
@@ -125,6 +132,7 @@ fn main() -> ExitCode {
|
||||
Some("merge") => merge(&args[1..]),
|
||||
Some("synth-merge") => synth_merge(&args[1..]),
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("calibrate") => calibrate(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -144,6 +152,114 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
|
||||
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
|
||||
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
|
||||
if path.is_file() {
|
||||
if is_raw(path) {
|
||||
out.push(path.to_path_buf());
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(path) else { return };
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with(['.', '@']) {
|
||||
continue;
|
||||
}
|
||||
raw_files(&entry.path(), out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn calibrate(args: &[String]) -> Result<(), String> {
|
||||
let mut max = 300usize;
|
||||
let mut out: Option<std::path::PathBuf> = None;
|
||||
let mut inputs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--max" => max = take_value(args, &mut i, "--max")?.parse().map_err(|_| "--max needs a number".to_string())?,
|
||||
"--out" => out = Some(take_value(args, &mut i, "--out")?.into()),
|
||||
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
|
||||
f => inputs.push(f.to_string()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if inputs.is_empty() {
|
||||
return Err("calibrate needs folders or raw files".into());
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
for input in &inputs {
|
||||
raw_files(Path::new(input), &mut files, 0);
|
||||
}
|
||||
files.sort();
|
||||
files.dedup();
|
||||
let found = files.len();
|
||||
if max > 0 && files.len() > max {
|
||||
// spread over all folders (dates, scenes) rather than the first N
|
||||
files = (0..max).filter_map(|k| files.get(k * found / max).cloned()).collect();
|
||||
}
|
||||
eprintln!("calibrate: {} of {found} raw files", files.len());
|
||||
let dir = out.or_else(lightcraft_engine::camera_profiles::dir).ok_or("no profiles folder: pass --out DIR")?;
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6);
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let done = std::sync::atomic::AtomicUsize::new(0);
|
||||
let pools: Vec<lightcraft_engine::camera_profiles::Pool> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
|
||||
while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes));
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
match result {
|
||||
Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()),
|
||||
Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()),
|
||||
Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()),
|
||||
}
|
||||
}
|
||||
pool
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect()
|
||||
});
|
||||
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
|
||||
for p in pools {
|
||||
pool.merge(p);
|
||||
}
|
||||
let mut written = 0;
|
||||
for result in pool.fit(5) {
|
||||
match result {
|
||||
Ok(profile) => {
|
||||
let path = lightcraft_engine::camera_profiles::save(&profile, &dir)?;
|
||||
println!(
|
||||
"{}: {} photos, {} colour pairs, hue/saturation table {} → {}",
|
||||
profile.model,
|
||||
profile.files,
|
||||
profile.samples,
|
||||
profile.hue_sat.is_some(),
|
||||
path.display()
|
||||
);
|
||||
written += 1;
|
||||
}
|
||||
Err(e) => eprintln!("calibrate: {e}"),
|
||||
}
|
||||
}
|
||||
for (model, n) in pool.files() {
|
||||
if n < 5 {
|
||||
eprintln!("calibrate: {model}: only {n} usable photo(s), at least 5 needed");
|
||||
}
|
||||
}
|
||||
if written == 0 {
|
||||
return Err("no profile written".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut demo = false;
|
||||
@@ -416,39 +532,39 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
if steps.is_empty() {
|
||||
return Err("run: no command given (see `lightcraft-cli commands`)".into());
|
||||
}
|
||||
let mut backend: Box<dyn Backend> = match connect {
|
||||
Some(addr) => {
|
||||
if demo || library.is_some() || !imports.is_empty() {
|
||||
return Err("--demo, --library and --import apply to headless mode only".into());
|
||||
}
|
||||
Box::new(
|
||||
Remote::connect(&addr)
|
||||
.map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
None => Headless::default(),
|
||||
};
|
||||
if !imports.is_empty() {
|
||||
let paths = expand_paths(&imports);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
// what follows acts on the imported photos (already-known files are reported as duplicates)
|
||||
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
|
||||
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
|
||||
if let Some(first) = ids.first().cloned() {
|
||||
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
|
||||
let mut backend: Box<dyn Backend> =
|
||||
match connect {
|
||||
Some(addr) => {
|
||||
if demo || library.is_some() || !imports.is_empty() {
|
||||
return Err("--demo, --library and --import apply to headless mode only".into());
|
||||
}
|
||||
Box::new(Remote::connect(&addr).map_err(|e| {
|
||||
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
|
||||
})?)
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
None => {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
None => Headless::default(),
|
||||
};
|
||||
if !imports.is_empty() {
|
||||
let paths = expand_paths(&imports);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
// what follows acts on the imported photos (already-known files are reported as duplicates)
|
||||
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
|
||||
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
|
||||
if let Some(first) = ids.first().cloned() {
|
||||
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut failed = 0;
|
||||
for s in &steps {
|
||||
@@ -474,6 +590,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
|
||||
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
|
||||
}
|
||||
|
||||
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
|
||||
/// no port, so a hint is never built from a guess.
|
||||
fn connect_port(addr: &str) -> &str {
|
||||
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut output = None;
|
||||
|
||||
@@ -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 {
|
||||
@@ -256,7 +292,9 @@ fn devices_are_listed_and_imported_from() {
|
||||
assert_eq!(dev["name"], "CARD", "{line}");
|
||||
// the import review of its DCIM folder finds the photo
|
||||
let dcim = dev["path"].as_str().unwrap().to_string();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap();
|
||||
// JSON-escaped: a Windows path has backslashes
|
||||
let paths = serde_json::to_string(&[&dcim]).unwrap();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
@@ -288,3 +326,13 @@ fn a_library_open_in_another_process_is_refused() {
|
||||
assert_eq!(lines[0]["ok"], true);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port
|
||||
/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
|
||||
#[test]
|
||||
fn connect_failure_hint_names_the_attempted_port() {
|
||||
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
}
|
||||
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow"] }
|
||||
|
||||
@@ -232,6 +232,18 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is there a file at `path`?
|
||||
pub async fn exists(&self, path: &str) -> Result<bool, String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => Ok(Self::opfs_file(root, path, false).await?.is_some()),
|
||||
Backend::Idb(db) => {
|
||||
let (_tx, store) = Self::idb_store(db, false)?;
|
||||
let n = idb_req(&store.count_with_key(&path.into()).map_err(err)?).await?;
|
||||
Ok(n.as_f64().is_some_and(|n| n > 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, path: &str) -> Result<(), String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => {
|
||||
@@ -309,14 +321,25 @@ async fn dir_keys(dir: &FileSystemDirectoryHandle) -> Result<Vec<String>, String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ask the browser not to evict our storage under pressure (best effort; fire and forget).
|
||||
pub fn request_persistence() {
|
||||
if let Some(sm) = storage_manager()
|
||||
&& let Ok(p) = sm.persist()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let granted = JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy());
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted (best effort)" });
|
||||
});
|
||||
}
|
||||
/// Ask the browser not to evict our storage under pressure; `on_result(granted)` once known
|
||||
/// (`false` also when the browser has no storage manager).
|
||||
pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) {
|
||||
let Some(sm) = storage_manager() else {
|
||||
on_result(false);
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// already persistent: nothing to ask
|
||||
let already = match sm.persisted() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
let granted = already
|
||||
|| match sm.persist() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" });
|
||||
on_result(granted);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Library backups for the browser build: a plain zip (entries stored, not compressed — photos
|
||||
//! don't shrink) of the library files and every stored original, built one entry at a time so
|
||||
//! only one file is in wasm memory at once, and read back the same way for a restore.
|
||||
//!
|
||||
//! Layout of a backup:
|
||||
//! - `library/catalog.snap`, `library/catalog.log`, `library/presets.json`, … (see
|
||||
//! [`crate::files::LIBRARY_FILES`]);
|
||||
//! - `originals/<content hash>/<file name>`: each imported photo's original bytes, under its own
|
||||
//! name so an unzipped backup is browsable;
|
||||
//! - `README.txt`.
|
||||
//!
|
||||
//! Restoring never deletes anything: the library files go to a new library folder in browser
|
||||
//! storage, originals that aren't stored yet are added (they're named by content, so existing
|
||||
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
|
||||
//! the restored library. The library that was there before stays in storage.
|
||||
//!
|
||||
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
|
||||
|
||||
/// Storage key of the file naming the library folder in use (absent: `library`).
|
||||
pub const ACTIVE_LIBRARY: &str = "active-library";
|
||||
|
||||
/// The default library folder.
|
||||
pub const DEFAULT_LIBRARY_DIR: &str = "library";
|
||||
|
||||
/// Largest backup (offsets are 32-bit without zip64).
|
||||
pub const MAX_BYTES: u64 = 0xFFFF_FFFF - (64 << 20);
|
||||
|
||||
const LOCAL_SIG: u32 = 0x0403_4b50;
|
||||
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
||||
const END_SIG: u32 = 0x0605_4b50;
|
||||
/// General-purpose flag bit 11: names are UTF-8.
|
||||
const UTF8: u16 = 0x0800;
|
||||
/// 1980-01-01 00:00 in MS-DOS format (date, time).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
|
||||
/// The readme inside every backup.
|
||||
pub const README: &str = "LightCraft library backup (browser version)\n\n\
|
||||
library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\
|
||||
originals/ every imported photo, as originals/<content hash>/<file name>\n\n\
|
||||
Restore it in LightCraft for the web with File > Restore Library from Backup...\n\
|
||||
The photos in originals/ are the untouched files you imported; edits live in the catalog.\n";
|
||||
|
||||
/// Is `name` a safe library folder name (`library` or `library-<letters, digits, ->`)?
|
||||
pub fn valid_library_dir(name: &str) -> bool {
|
||||
name == DEFAULT_LIBRARY_DIR
|
||||
|| name.strip_prefix("library-").is_some_and(|s| !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
}
|
||||
|
||||
/// Is `h` a plausible content hash (used as a storage key segment)?
|
||||
pub fn valid_hash(h: &str) -> bool {
|
||||
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Zip entry name for an original.
|
||||
pub fn original_entry(hash: &str, file_name: &str) -> String {
|
||||
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
|
||||
let name = if name.is_empty() || name == "." || name == ".." { "original".to_string() } else { name };
|
||||
format!("originals/{hash}/{name}")
|
||||
}
|
||||
|
||||
/// Where a backup entry goes when restoring into library folder `dir`: `Some(storage key)`, or
|
||||
/// `None` to skip it (README, thumbnails, anything unknown or unsafe).
|
||||
pub fn restore_key(entry: &str, dir: &str) -> Option<String> {
|
||||
if let Some(name) = entry.strip_prefix("library/") {
|
||||
return crate::files::LIBRARY_FILES.contains(&name).then(|| format!("{dir}/{name}"));
|
||||
}
|
||||
let rest = entry.strip_prefix("originals/")?;
|
||||
let (hash, _) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
valid_hash(hash).then(|| format!("originals/{hash}"))
|
||||
}
|
||||
|
||||
fn u16le(v: &mut Vec<u8>, x: u16) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
fn u32le(v: &mut Vec<u8>, x: u32) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
struct Central {
|
||||
name: String,
|
||||
crc: u32,
|
||||
size: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
/// Writes a stored (uncompressed) zip piece by piece: [`ZipWriter::entry`] returns the local
|
||||
/// header to emit before the entry's bytes, [`ZipWriter::finish`] the central directory.
|
||||
#[derive(Default)]
|
||||
pub struct ZipWriter {
|
||||
entries: Vec<Central>,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
impl ZipWriter {
|
||||
/// The local header for `name` holding `data` (emit it, then `data`).
|
||||
pub fn entry(&mut self, name: &str, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let size = u32::try_from(data.len()).map_err(|_| format!("{name}: larger than 4 GB"))?;
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| format!("{name}: name too long"))?;
|
||||
if self.entries.len() >= usize::from(u16::MAX) {
|
||||
return Err("too many files for one backup".into());
|
||||
}
|
||||
let header_len = 30 + u64::from(name_len);
|
||||
if self.offset + header_len + u64::from(size) > MAX_BYTES {
|
||||
return Err("the library is too large for one backup file (4 GB)".into());
|
||||
}
|
||||
let offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let crc = crc32fast::hash(data);
|
||||
let mut h = Vec::with_capacity(header_len as usize);
|
||||
u32le(&mut h, LOCAL_SIG);
|
||||
u16le(&mut h, 20); // version needed
|
||||
u16le(&mut h, UTF8);
|
||||
u16le(&mut h, 0); // stored
|
||||
u16le(&mut h, 0); // time
|
||||
u16le(&mut h, DOS_DATE);
|
||||
u32le(&mut h, crc);
|
||||
u32le(&mut h, size);
|
||||
u32le(&mut h, size);
|
||||
u16le(&mut h, name_len);
|
||||
u16le(&mut h, 0); // extra
|
||||
h.extend_from_slice(name.as_bytes());
|
||||
self.offset += header_len + u64::from(size);
|
||||
self.entries.push(Central { name: name.to_string(), crc, size, offset });
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The central directory and end record (emit last).
|
||||
pub fn finish(self) -> Result<Vec<u8>, String> {
|
||||
let mut cd = Vec::new();
|
||||
for e in &self.entries {
|
||||
u32le(&mut cd, CENTRAL_SIG);
|
||||
u16le(&mut cd, 20); // made by
|
||||
u16le(&mut cd, 20); // needed
|
||||
u16le(&mut cd, UTF8);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, DOS_DATE);
|
||||
u32le(&mut cd, e.crc);
|
||||
u32le(&mut cd, e.size);
|
||||
u32le(&mut cd, e.size);
|
||||
u16le(&mut cd, e.name.len() as u16);
|
||||
u16le(&mut cd, 0); // extra
|
||||
u16le(&mut cd, 0); // comment
|
||||
u16le(&mut cd, 0); // disk
|
||||
u16le(&mut cd, 0); // internal attrs
|
||||
u32le(&mut cd, 0); // external attrs
|
||||
u32le(&mut cd, e.offset);
|
||||
cd.extend_from_slice(e.name.as_bytes());
|
||||
}
|
||||
let cd_offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let cd_size = u32::try_from(cd.len()).map_err(|_| "backup too large".to_string())?;
|
||||
let n = self.entries.len() as u16;
|
||||
let mut out = cd;
|
||||
u32le(&mut out, END_SIG);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, n);
|
||||
u16le(&mut out, n);
|
||||
u32le(&mut out, cd_size);
|
||||
u32le(&mut out, cd_offset);
|
||||
u16le(&mut out, 0); // comment
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a zip's central directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ZipEntry {
|
||||
pub name: String,
|
||||
pub crc: u32,
|
||||
pub size: u64,
|
||||
/// Offset of the entry's local header.
|
||||
pub header: u64,
|
||||
/// 0 = stored (the only method a restore reads).
|
||||
pub method: u16,
|
||||
}
|
||||
|
||||
fn rd16(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?))
|
||||
}
|
||||
fn rd32(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// How many bytes from the end of a zip to read to find its end record.
|
||||
pub fn tail_len(file_len: u64) -> u64 {
|
||||
file_len.min(22 + 65_535)
|
||||
}
|
||||
|
||||
/// The central directory's (offset, size) from the last [`tail_len`] bytes of a zip.
|
||||
pub fn find_central(tail: &[u8]) -> Result<(u64, u64), String> {
|
||||
let at = (0..tail.len().saturating_sub(21)).rev().find(|&i| rd32(tail, i) == Some(END_SIG)).ok_or("not a zip file (no end record)")?;
|
||||
let size = rd32(tail, at + 12).ok_or("truncated zip")?;
|
||||
let offset = rd32(tail, at + 16).ok_or("truncated zip")?;
|
||||
if size == u32::MAX || offset == u32::MAX {
|
||||
return Err("zip64 backups are not supported".into());
|
||||
}
|
||||
Ok((u64::from(offset), u64::from(size)))
|
||||
}
|
||||
|
||||
/// Parse a central directory.
|
||||
pub fn parse_central(cd: &[u8]) -> Result<Vec<ZipEntry>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 46 <= cd.len() {
|
||||
if rd32(cd, i) != Some(CENTRAL_SIG) {
|
||||
return Err("damaged zip directory".into());
|
||||
}
|
||||
let bad = || "damaged zip directory".to_string();
|
||||
let method = rd16(cd, i + 10).ok_or_else(bad)?;
|
||||
let crc = rd32(cd, i + 16).ok_or_else(bad)?;
|
||||
let size = rd32(cd, i + 24).ok_or_else(bad)?;
|
||||
let n = usize::from(rd16(cd, i + 28).ok_or_else(bad)?);
|
||||
let extra = usize::from(rd16(cd, i + 30).ok_or_else(bad)?);
|
||||
let comment = usize::from(rd16(cd, i + 32).ok_or_else(bad)?);
|
||||
let header = rd32(cd, i + 42).ok_or_else(bad)?;
|
||||
let name = cd.get(i + 46..i + 46 + n).ok_or_else(bad)?;
|
||||
out.push(ZipEntry { name: String::from_utf8_lossy(name).into_owned(), crc, size: u64::from(size), header: u64::from(header), method });
|
||||
i += 46 + n + extra + comment;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Offset of an entry's data from its local header's first 30 bytes.
|
||||
pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
|
||||
if rd32(local, 0) != Some(LOCAL_SIG) {
|
||||
return Err(format!("{}: damaged entry", entry.name));
|
||||
}
|
||||
let n = rd16(local, 26).ok_or("truncated entry")?;
|
||||
let extra = rd16(local, 28).ok_or("truncated entry")?;
|
||||
Ok(entry.header + 30 + u64::from(n) + u64::from(extra))
|
||||
}
|
||||
|
||||
/// Check an entry's bytes.
|
||||
pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
||||
if entry.method != 0 {
|
||||
return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name));
|
||||
}
|
||||
if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc {
|
||||
return Err(format!("{}: damaged (checksum mismatch)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name for the restored library's folder.
|
||||
pub fn restored_dir_name(now_ms: f64) -> String {
|
||||
format!("library-restored-{}", now_ms.max(0.0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut w = ZipWriter::default();
|
||||
let mut out = Vec::new();
|
||||
for (n, d) in files {
|
||||
out.extend(w.entry(n, d).unwrap());
|
||||
out.extend_from_slice(d);
|
||||
}
|
||||
out.extend(w.finish().unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_backups_read_back() {
|
||||
let files: [(&str, &[u8]); 3] =
|
||||
[("README.txt", README.as_bytes()), ("library/catalog.log", b"{\"seq\":1}\n"), ("originals/abc123/IMG 1.jpg", &[0, 1, 2, 3, 255])];
|
||||
let zip = build(&files);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
for (e, (name, data)) in entries.iter().zip(files) {
|
||||
assert_eq!(e.name, name);
|
||||
let start = data_offset(e, &zip[e.header as usize..e.header as usize + 30]).unwrap() as usize;
|
||||
let got = &zip[start..start + e.size as usize];
|
||||
assert_eq!(got, data);
|
||||
verify(e, got).unwrap();
|
||||
}
|
||||
// damage is caught
|
||||
let e = &entries[2];
|
||||
assert!(verify(e, &[0, 1, 2, 3, 254]).is_err());
|
||||
assert!(find_central(b"not a zip at all, just some text").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keys_are_safe() {
|
||||
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
|
||||
assert_eq!(restore_key("library/../../x", "library-r"), None);
|
||||
assert_eq!(restore_key("library/evil.bin", "library-r"), None);
|
||||
assert_eq!(restore_key("originals/abc123/IMG.jpg", "d").as_deref(), Some("originals/abc123"));
|
||||
assert_eq!(restore_key("originals/../library/catalog.log", "d"), None);
|
||||
assert_eq!(restore_key("README.txt", "d"), None);
|
||||
assert_eq!(restore_key("thumbs/x.jpg", "d"), None);
|
||||
assert_eq!(original_entry("abc", "a/b\\c.jpg"), "originals/abc/a_b_c.jpg");
|
||||
assert_eq!(original_entry("abc", ".."), "originals/abc/original");
|
||||
assert!(valid_library_dir("library") && valid_library_dir("library-restored-17"));
|
||||
assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limits_are_errors_not_panics() {
|
||||
let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() };
|
||||
assert!(w.entry("x", &[0; 64]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ struct Mirror {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
/// Dirty files, least recently modified first.
|
||||
dirty: Vec<(String, Dirty)>,
|
||||
/// Why the last flush to browser storage failed (quota, eviction…), until one succeeds.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
@@ -138,6 +140,18 @@ impl Files {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the outcome of a flush to browser storage: `Some(why)` when it failed, `None` once
|
||||
/// one succeeds. While a flush is failing, the catalog [`Store`] refuses new writes, so the
|
||||
/// engine keeps the changes queued and reports them unsaved (`library.info` → `unsavedOps`,
|
||||
/// the top bar's warning) instead of pretending they were saved.
|
||||
pub fn set_error(&self, e: Option<String>) {
|
||||
self.lock().error = e;
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.lock().error.clone()
|
||||
}
|
||||
|
||||
/// A catalog [`Store`] over these files.
|
||||
pub fn store(&self) -> FilesStore {
|
||||
FilesStore(self.clone())
|
||||
@@ -147,19 +161,32 @@ impl Files {
|
||||
/// [`Store`] implementation over [`Files`].
|
||||
pub struct FilesStore(Files);
|
||||
|
||||
impl FilesStore {
|
||||
/// Writes fail while browser storage does (see [`Files::set_error`]).
|
||||
fn writable(&self) -> io::Result<()> {
|
||||
match self.0.error() {
|
||||
Some(e) => Err(io::Error::other(format!("browser storage: {e}"))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for FilesStore {
|
||||
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
Ok(self.0.get(name))
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.write(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.append(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.truncate(name, len as usize);
|
||||
Ok(())
|
||||
}
|
||||
@@ -224,6 +251,37 @@ mod tests {
|
||||
assert_eq!(names, ["catalog.snap", "catalog.log"]);
|
||||
}
|
||||
|
||||
/// A failing flush (quota exceeded, storage evicted) is not silent: commands report the change
|
||||
/// as unsaved and the engine keeps it queued; once storage works again it is written.
|
||||
#[test]
|
||||
fn flush_failures_show_as_unsaved_and_are_retried() {
|
||||
let mut disk = BTreeMap::new();
|
||||
let files = Files::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
flush(&files, &mut disk);
|
||||
files.set_error(Some("QuotaExceededError".into()));
|
||||
let r = s.execute("photo.rate", &json!({"rating": 5}));
|
||||
assert!(matches!(r, Err(lightcraft_engine::EngineError::NotSaved(_))), "{r:?}");
|
||||
let (ops, why) = s.unsaved().unwrap();
|
||||
assert!(ops > 0 && why.contains("QuotaExceeded"), "{why}");
|
||||
files.set_error(None);
|
||||
s.persist_if_dirty();
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_none(), "written once storage works again");
|
||||
flush(&files, &mut disk);
|
||||
let id = s.selection.active.unwrap();
|
||||
let files2 = Files::default();
|
||||
for (k, v) in &disk {
|
||||
files2.preload(k, v.clone());
|
||||
}
|
||||
let mut s2 = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files2.store()), files: Box::new(files2.store()), on_disk: false };
|
||||
s2.open_library_in(stores, true).unwrap();
|
||||
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_survives_reload_through_flushes() {
|
||||
let mut disk = BTreeMap::new();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod backup;
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
pub mod store;
|
||||
@@ -24,6 +25,8 @@ pub mod wire;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod backend;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod safety;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod workers;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the
|
||||
//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message
|
||||
//! instead of a dead page after a panic, and backup / restore of the whole library
|
||||
//! ([`crate::backup`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use js_sys::{Reflect, Uint8Array};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
valid_hash, verify,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
use crate::store::storage_key;
|
||||
|
||||
thread_local! {
|
||||
/// Messages for the user, shown by the app on its next frame.
|
||||
static NOTICES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Queue a message for the user (shown for several seconds; also logged).
|
||||
pub fn notice(text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
log::warn!("{text}");
|
||||
NOTICES.with(|n| n.borrow_mut().push(text));
|
||||
crate::web::request_repaint();
|
||||
}
|
||||
|
||||
/// Messages queued since the last call.
|
||||
pub fn take_notices() -> Vec<String> {
|
||||
NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut()))
|
||||
}
|
||||
|
||||
fn document() -> Option<web_sys::Document> {
|
||||
web_sys::window()?.document()
|
||||
}
|
||||
|
||||
/// Ask the user (the browser's own confirm box). `false` when it can't be shown.
|
||||
pub fn confirm(text: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cover the page with a message (the app can't continue, or must not start).
|
||||
pub fn show_blocking(text: &str) {
|
||||
let Some(doc) = document() else { return };
|
||||
let el = match doc.get_element_by_id("lightcraft_blocking") {
|
||||
Some(el) => el,
|
||||
None => {
|
||||
let Ok(el) = doc.create_element("div") else { return };
|
||||
el.set_id("lightcraft_blocking");
|
||||
let _ = el.set_attribute(
|
||||
"style",
|
||||
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
|
||||
background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line",
|
||||
);
|
||||
if let Some(body) = doc.body() {
|
||||
let _ = body.append_child(&el);
|
||||
}
|
||||
el
|
||||
}
|
||||
};
|
||||
el.set_text_content(Some(text));
|
||||
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
|
||||
loading.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a
|
||||
/// frozen canvas, and say that the stored library is intact.
|
||||
pub fn install_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown error".into(),
|
||||
};
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
log::error!("LightCraft panicked{at}: {msg}");
|
||||
show_blocking(&format!(
|
||||
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
|
||||
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
|
||||
));
|
||||
}));
|
||||
}
|
||||
|
||||
/// The name of the lock that keeps a library to one tab.
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
log::warn!("Web Locks request failed: {e:?}");
|
||||
}
|
||||
});
|
||||
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> {
|
||||
let e = |e: JsValue| format!("download failed: {e:?}");
|
||||
let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?;
|
||||
let doc = document().ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
// revoke once the download has started
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and
|
||||
/// every original in storage, built entry by entry into the browser's memory (outside the wasm
|
||||
/// heap) and downloaded. `names`: content hash → the photo's file name.
|
||||
pub async fn backup(files: Files, backend: Backend, names: HashMap<String, String>) -> Result<(usize, f64), String> {
|
||||
let parts = js_sys::Array::new();
|
||||
let mut zip = ZipWriter::default();
|
||||
let mut bytes = 0f64;
|
||||
let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> {
|
||||
let header = zip.entry(name, data)?;
|
||||
parts.push(&Uint8Array::from(&header[..]));
|
||||
parts.push(&Uint8Array::from(data));
|
||||
bytes += (header.len() + data.len()) as f64;
|
||||
Ok(())
|
||||
};
|
||||
add(&mut zip, "README.txt", README.as_bytes())?;
|
||||
for name in LIBRARY_FILES {
|
||||
if let Some(d) = files.get(name) {
|
||||
add(&mut zip, &format!("library/{name}"), &d)?;
|
||||
}
|
||||
}
|
||||
let mut originals = 0;
|
||||
for hash in backend.list("originals").await? {
|
||||
if !valid_hash(&hash) {
|
||||
continue;
|
||||
}
|
||||
match backend.read(&storage_key(&hash)).await {
|
||||
Ok(Some(data)) => {
|
||||
let name = names.get(&hash).map_or("original", String::as_str);
|
||||
add(&mut zip, &original_entry(&hash, name), &data)?;
|
||||
originals += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => return Err(format!("reading an original from browser storage: {e}")),
|
||||
}
|
||||
}
|
||||
let tail = zip.finish()?;
|
||||
parts.push(&Uint8Array::from(&tail[..]));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/zip");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
|
||||
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
|
||||
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
|
||||
Ok((originals, bytes + tail.len() as f64))
|
||||
}
|
||||
|
||||
/// Bytes `start..end` of a picked file.
|
||||
async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
|
||||
let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?;
|
||||
let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten),
|
||||
/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page.
|
||||
pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, String> {
|
||||
let len = file.size() as u64;
|
||||
let tail = read_range(&file, len - tail_len(len), len).await?;
|
||||
let (cd_offset, cd_size) = find_central(&tail)?;
|
||||
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
|
||||
let entries = parse_central(&cd)?;
|
||||
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
|
||||
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
|
||||
}
|
||||
let dir = restored_dir_name(js_sys::Date::now());
|
||||
let mut written = 0usize;
|
||||
for e in &entries {
|
||||
let Some(key) = restore_key(&e.name, &dir) else { continue };
|
||||
// originals are named by their content: one already stored is the same file
|
||||
if key.starts_with("originals/") && backend.exists(&key).await? {
|
||||
continue;
|
||||
}
|
||||
let local = read_range(&file, e.header, e.header + 30).await?;
|
||||
let start = data_offset(e, &local)?;
|
||||
let data = read_range(&file, start, start + e.size).await?;
|
||||
verify(e, &data)?;
|
||||
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
|
||||
written += 1;
|
||||
}
|
||||
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
|
||||
log::info!("lightcraft: restored {written} files into {dir}");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
|
||||
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
|
||||
let ok = confirm(
|
||||
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
|
||||
The current library is not deleted: it stays in browser storage.",
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = document() else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".zip,application/zip");
|
||||
let inp = input.clone();
|
||||
let mut before_reload = Some(before_reload);
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
let Some(file) = inp.files().and_then(|f| f.get(0)) else { return };
|
||||
let backend = backend.clone();
|
||||
let before = before_reload.take();
|
||||
notice(format!("Restoring {}…", file.name()));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match restore(file, backend).await {
|
||||
Ok(_) => {
|
||||
if let Some(f) = before {
|
||||
f();
|
||||
}
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.location().reload();
|
||||
}
|
||||
}
|
||||
Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")),
|
||||
}
|
||||
});
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
on_change.forget();
|
||||
input.click();
|
||||
}
|
||||
+144
-22
@@ -15,16 +15,21 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir};
|
||||
use crate::bench::Bench;
|
||||
use crate::files::{Files, FlushOp, LIBRARY_FILES};
|
||||
use crate::safety::{self, notice};
|
||||
use crate::store::{Originals, content_hash, download_name, storage_key};
|
||||
use crate::wire::ThumbIndex;
|
||||
use crate::workers::{THUMB_INDEX, Workers};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
/// Where the library files live in browser storage.
|
||||
const LIBRARY_DIR: &str = "library";
|
||||
/// After a failed save, wait this long before trying again (ms).
|
||||
const SAVE_RETRY_MS: f64 = 2000.0;
|
||||
|
||||
/// How long a notice stays on screen (s).
|
||||
const NOTICE_SECS: f64 = 8.0;
|
||||
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
@@ -45,8 +50,18 @@ fn set_status(text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn library_key(name: &str) -> String {
|
||||
format!("{LIBRARY_DIR}/{name}")
|
||||
/// Storage key of library file `name` in library folder `dir`.
|
||||
fn library_key(dir: &str, name: &str) -> String {
|
||||
format!("{dir}/{name}")
|
||||
}
|
||||
|
||||
/// Repaint the app (notices from async tasks).
|
||||
pub(crate) fn request_repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// URL options (`?bench&store=idb&workers=2&reset`).
|
||||
@@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option<Backend>, name: String
|
||||
if let Some(b) = &backend
|
||||
&& let Err(e) = b.write(&storage_key(&hash), &bytes).await
|
||||
{
|
||||
// still importable for this session; it won't survive a reload
|
||||
log::error!("storing {name} in browser storage failed: {e}");
|
||||
// a photo whose bytes aren't stored would be gone after a reload: don't add it
|
||||
notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos."));
|
||||
return;
|
||||
}
|
||||
originals.added(&name, &hash, Arc::from(bytes));
|
||||
ctx.request_repaint();
|
||||
@@ -148,8 +164,42 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context) -> Services {
|
||||
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
let names: std::collections::HashMap<String, String> = session
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let files = backup_files.clone();
|
||||
notice("Backing up the library… (the download starts when it's ready)");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match safety::backup(files, b, names).await {
|
||||
Ok((n, bytes)) => notice(format!(
|
||||
"Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
bytes / 1e6
|
||||
)),
|
||||
Err(e) => notice(format!("Backup failed: {e}")),
|
||||
}
|
||||
});
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
restore_library: Some(Box::new(move |_session: &mut Session| {
|
||||
let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
let frozen = frozen.clone();
|
||||
// once the backup is in place, stop saving the old library (the page reloads)
|
||||
safety::pick_and_restore(b, move || frozen.set(true));
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
pick_files: Some(Box::new(move || {
|
||||
open_picker(originals.clone(), backend.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
@@ -176,21 +226,29 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write dirty library files to storage, in order, until none are left.
|
||||
async fn flush(files: Files, backend: Backend, flushing: Rc<Cell<bool>>) {
|
||||
/// Write dirty library files to storage, in order, until none are left. A failure (quota,
|
||||
/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead
|
||||
/// of accepting them silently — and retried after [`SAVE_RETRY_MS`].
|
||||
async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bool>>, failed_at: Rc<Cell<f64>>) {
|
||||
'outer: loop {
|
||||
let ops = files.take_dirty();
|
||||
if ops.is_empty() {
|
||||
if files.error().is_some() {
|
||||
log::info!("lightcraft: saving works again");
|
||||
}
|
||||
files.set_error(None);
|
||||
break;
|
||||
}
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let r = match op {
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await,
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::error!("saving {} failed: {e}", op.name());
|
||||
files.failed(&ops[i..]);
|
||||
files.set_error(Some(e));
|
||||
failed_at.set(perf_now());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
@@ -204,6 +262,8 @@ struct Boot {
|
||||
backend: Option<Backend>,
|
||||
files: Files,
|
||||
index: ThumbIndex,
|
||||
/// The library folder in storage (`library`, or a restored backup's).
|
||||
lib_dir: String,
|
||||
}
|
||||
|
||||
async fn boot(opts: Options) -> Boot {
|
||||
@@ -221,15 +281,32 @@ async fn boot(opts: Options) -> Boot {
|
||||
};
|
||||
let files = Files::default();
|
||||
let mut index = ThumbIndex::default();
|
||||
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
// everything this site keeps in the browser, photos included: never without asking
|
||||
let ok = safety::confirm(
|
||||
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
|
||||
This can't be undone. Delete it?",
|
||||
);
|
||||
if ok {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
}
|
||||
} else {
|
||||
log::info!("lightcraft: ?reset declined; storage kept");
|
||||
}
|
||||
}
|
||||
// a restored backup lives in its own folder (see `backup`)
|
||||
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
|
||||
let d = String::from_utf8_lossy(&d).trim().to_string();
|
||||
if valid_library_dir(&d) {
|
||||
lib_dir = d;
|
||||
}
|
||||
}
|
||||
for name in LIBRARY_FILES {
|
||||
match b.read(&library_key(name)).await {
|
||||
match b.read(&library_key(&lib_dir, name)).await {
|
||||
Ok(Some(data)) => files.preload(name, data),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("reading {name}: {e}"),
|
||||
@@ -238,7 +315,14 @@ async fn boot(opts: Options) -> Boot {
|
||||
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
|
||||
index = ThumbIndex::from_json(&j);
|
||||
}
|
||||
crate::backend::request_persistence();
|
||||
crate::backend::request_persistence(|granted| {
|
||||
if !granted {
|
||||
notice(
|
||||
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
|
||||
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
|
||||
);
|
||||
}
|
||||
});
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
let b2 = b.clone();
|
||||
let known: std::collections::HashSet<String> = b
|
||||
@@ -266,7 +350,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
index.len(),
|
||||
index.total() as f64 / 1e6
|
||||
);
|
||||
Boot { opts, backend, files, index }
|
||||
Boot { opts, backend, files, index, lib_dir }
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
@@ -276,6 +360,11 @@ struct WebApp {
|
||||
backend: Option<Backend>,
|
||||
workers: Option<Workers>,
|
||||
flushing: Rc<Cell<bool>>,
|
||||
/// When the last save to storage failed (ms; 0 = never).
|
||||
flush_failed_at: Rc<Cell<f64>>,
|
||||
/// Saving stopped (a restored backup is about to replace this library: the page reloads).
|
||||
frozen: Rc<Cell<bool>>,
|
||||
lib_dir: String,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
last_save: f64,
|
||||
@@ -284,13 +373,13 @@ struct WebApp {
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
let Boot { opts, backend, files, index } = boot;
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::new();
|
||||
originals.install(&mut session);
|
||||
let stores = LibraryStores {
|
||||
dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
catalog: Box::new(files.store()),
|
||||
files: Box::new(files.store()),
|
||||
on_disk: false,
|
||||
@@ -319,9 +408,16 @@ impl WebApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
if backend.is_some() {
|
||||
notice(
|
||||
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
|
||||
elsewhere and back up with File ▸ Back Up Library….",
|
||||
);
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone()));
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
app.ui = ui;
|
||||
@@ -332,8 +428,9 @@ impl WebApp {
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let cache = app.session.media.rendered.clone();
|
||||
let workers =
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone()));
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone()));
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
@@ -347,6 +444,9 @@ impl WebApp {
|
||||
backend,
|
||||
workers,
|
||||
flushing: Rc::new(Cell::new(false)),
|
||||
flush_failed_at: Rc::new(Cell::new(0.0)),
|
||||
frozen,
|
||||
lib_dir,
|
||||
bench: opts.bench.then(|| Bench::new(origin)),
|
||||
first_frame_logged: false,
|
||||
last_save: 0.0,
|
||||
@@ -408,6 +508,9 @@ impl WebApp {
|
||||
|
||||
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
|
||||
fn save(&mut self) {
|
||||
if self.frozen.get() {
|
||||
return;
|
||||
}
|
||||
let now = perf_now();
|
||||
if now - self.last_save >= SAVE_EVERY_MS {
|
||||
self.last_save = now;
|
||||
@@ -429,12 +532,16 @@ impl WebApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
// after a failure, retry every couple of seconds rather than every frame
|
||||
let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS;
|
||||
if let Some(b) = &self.backend
|
||||
&& self.files.is_dirty()
|
||||
&& !self.flushing.get()
|
||||
&& !backing_off
|
||||
{
|
||||
self.flushing.set(true);
|
||||
wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone()));
|
||||
let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone());
|
||||
wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,8 +571,10 @@ impl WebApp {
|
||||
let (orig_n, orig_bytes) = self.originals.usage();
|
||||
json!({
|
||||
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
"library": self.lib_dir,
|
||||
"dirty": self.files.is_dirty(),
|
||||
"flushing": self.flushing.get(),
|
||||
"saveError": self.files.error(),
|
||||
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
|
||||
"originalsInMemory": orig_n,
|
||||
"originalBytesInMemory": orig_bytes,
|
||||
@@ -490,6 +599,10 @@ impl WebApp {
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
@@ -527,9 +640,18 @@ impl eframe::App for WebApp {
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
|
||||
safety::show_blocking(
|
||||
"LightCraft is already open in another tab or window of this browser.\n\n\
|
||||
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
|
||||
+162
-14
@@ -11,14 +11,13 @@
|
||||
//! ([`ThumbIndex`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use lightcraft_engine::catalog::Source;
|
||||
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::Lru;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_preview::{Hash128, Lru, PreviewCache};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::hash_of_path;
|
||||
@@ -26,11 +25,17 @@ use crate::store::hash_of_path;
|
||||
/// One render request for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WireJob {
|
||||
#[serde(default)]
|
||||
pub cache_generation: u64,
|
||||
#[serde(default)]
|
||||
pub source_identity: Option<String>,
|
||||
pub level: SourceLevel,
|
||||
pub origin: Source,
|
||||
/// Decode the source at most this long.
|
||||
pub max_edge: usize,
|
||||
pub raw: bool,
|
||||
#[serde(default)]
|
||||
pub relative_wb: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
@@ -55,14 +60,17 @@ impl WireJob {
|
||||
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.width.max(img.height),
|
||||
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
cache_generation: job.cache_generation,
|
||||
source_identity: job.source_key.map(|key| key.to_string()),
|
||||
level: job.level,
|
||||
origin: job.origin.clone(),
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
relative_wb: job.info.relative_wb,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
@@ -79,7 +87,14 @@ impl WireJob {
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SourceInfo {
|
||||
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
|
||||
SourceInfo {
|
||||
raw: self.raw,
|
||||
as_shot_temp: self.as_shot_temp,
|
||||
as_shot_tint: self.as_shot_tint,
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(&self) -> RenderRequest {
|
||||
@@ -99,8 +114,8 @@ impl WireJob {
|
||||
/// Identifies the decoded source this job needs.
|
||||
fn source_key(&self) -> String {
|
||||
match &self.origin {
|
||||
Source::File { path } => format!("{}:{path}", self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:demo:{scene}", self.max_edge),
|
||||
Source::File { path } => format!("{}:{:?}:{}:{path}", self.cache_generation, self.source_identity, self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:{:?}:{}:demo:{scene}", self.cache_generation, self.source_identity, self.max_edge),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +134,7 @@ const WORKER_SOURCE_BYTES: usize = 192 << 20;
|
||||
|
||||
/// The synchronous part of a worker: decoded-source cache + rendering.
|
||||
pub struct WorkerCore {
|
||||
sources: Lru<String, Arc<Rgb32f>>,
|
||||
sources: Lru<String, DecodedSource>,
|
||||
media: MediaCache,
|
||||
stages: HashMap<String, Arc<StageCache>>,
|
||||
}
|
||||
@@ -150,21 +165,25 @@ impl WorkerCore {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let src = match (&job.origin, original) {
|
||||
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
|
||||
(Source::File { .. }, Some(bytes)) => {
|
||||
let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?;
|
||||
DecodedSource::new(Arc::new(image), Some(info))
|
||||
}
|
||||
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?,
|
||||
};
|
||||
let cost = src.width * src.height * 12 + 64;
|
||||
let cost = src.image.width * src.image.height * 12 + 64;
|
||||
self.sources.insert(key, src.clone(), cost);
|
||||
src
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -173,6 +192,8 @@ impl WorkerCore {
|
||||
/// pruned like the native disk cache (least recently used first, down to 80 % of the budget).
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct ThumbIndex {
|
||||
#[serde(default)]
|
||||
namespace: u64,
|
||||
/// hex key → (bytes, last use).
|
||||
entries: HashMap<String, (u64, u64)>,
|
||||
clock: u64,
|
||||
@@ -183,6 +204,24 @@ pub struct ThumbIndex {
|
||||
}
|
||||
|
||||
impl ThumbIndex {
|
||||
pub fn namespace(&self) -> u64 {
|
||||
self.namespace
|
||||
}
|
||||
|
||||
/// A persistent new namespace prevents late old workers restoring an invalidated disk hit.
|
||||
pub fn cache_key(&self, key: Hash128) -> String {
|
||||
if self.namespace == 0 {
|
||||
return key.to_string();
|
||||
}
|
||||
lightcraft_preview::Hasher128::new().str(&key.to_string()).str("cleared").u64(self.namespace).finish().to_string()
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) -> Vec<String> {
|
||||
self.namespace = self.namespace.wrapping_add(1);
|
||||
self.total = 0;
|
||||
self.dirty = true;
|
||||
self.entries.drain().map(|(key, _)| key).collect()
|
||||
}
|
||||
pub fn from_json(bytes: &[u8]) -> ThumbIndex {
|
||||
let mut i: ThumbIndex = serde_json::from_slice(bytes).unwrap_or_default();
|
||||
i.total = i.entries.values().map(|e| e.0).sum();
|
||||
@@ -260,11 +299,55 @@ impl ThumbIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which rendered-thumbnail cache, at which generation, the stored thumbnails ([`ThumbIndex`])
|
||||
/// belong to (main thread). Clearing the previews bumps the cache's generation, and opening
|
||||
/// another library replaces the cache: either makes every stored thumbnail obsolete.
|
||||
pub struct CacheWatch(Option<(Weak<PreviewCache>, u64)>);
|
||||
|
||||
impl CacheWatch {
|
||||
/// Watch `active`, the session's cache when the workers start: the persisted index belongs
|
||||
/// to it at its current generation. (Without this baseline, the first observation after a
|
||||
/// clear would only record the cleared generation and keep the obsolete thumbnails.)
|
||||
pub fn new(active: &Arc<PreviewCache>) -> CacheWatch {
|
||||
CacheWatch(Some((Arc::downgrade(active), active.generation())))
|
||||
}
|
||||
|
||||
/// The watched cache, while it's alive.
|
||||
pub fn cache(&self) -> Option<Arc<PreviewCache>> {
|
||||
self.0.as_ref().and_then(|(cache, _)| cache.upgrade())
|
||||
}
|
||||
|
||||
/// `cache` at `generation` is the active one now. If it was cleared or replaced since the
|
||||
/// last observation, invalidate `index` and return the keys whose files should be deleted.
|
||||
pub fn observe(&mut self, cache: &Arc<PreviewCache>, generation: u64, index: &mut ThumbIndex) -> Vec<String> {
|
||||
let changed = self.0.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation);
|
||||
self.0 = Some((Arc::downgrade(cache), generation));
|
||||
if changed { index.invalidate() } else { Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn clearing_index_changes_disk_namespace_and_survives_restart() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let mut index = ThumbIndex::default();
|
||||
let old = index.cache_key(key);
|
||||
assert_eq!(old, key.to_string());
|
||||
index.insert(&old, 100);
|
||||
assert_eq!(index.invalidate(), vec![old.clone()]);
|
||||
let new = index.cache_key(key);
|
||||
assert_ne!(old, new);
|
||||
index.insert(&new, 100);
|
||||
let reopened = ThumbIndex::from_json(&index.to_json());
|
||||
assert_eq!(reopened.cache_key(key), new);
|
||||
assert!(!reopened.contains(&old));
|
||||
assert!(reopened.contains(&new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_job_renders_like_the_engine() {
|
||||
let mut s = Session::with_demo();
|
||||
@@ -291,10 +374,13 @@ mod tests {
|
||||
let hash = crate::store::content_hash(&bytes);
|
||||
let origin = Source::File { path: crate::store::original_path(&hash, "a.png") };
|
||||
let job = WireJob {
|
||||
cache_generation: 0,
|
||||
source_identity: None,
|
||||
level: SourceLevel::Thumb,
|
||||
origin,
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
relative_wb: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
@@ -314,6 +400,68 @@ mod tests {
|
||||
let r = core.render(&job, Some(&bytes)).unwrap();
|
||||
assert_eq!(r.image.width, 32);
|
||||
assert_eq!(core.needs_original(&job), None, "decoded source is kept");
|
||||
let mut reloaded = job.clone();
|
||||
reloaded.source_identity = Some("changed content".into());
|
||||
assert_eq!(core.needs_original(&reloaded).as_deref(), Some(hash.as_str()), "content reload must not reuse an old decoded source");
|
||||
let mut refreshed = job.clone();
|
||||
refreshed.cache_generation = 1;
|
||||
assert_eq!(core.needs_original(&refreshed).as_deref(), Some(hash.as_str()), "explicit refresh must not reuse the old decoded source");
|
||||
}
|
||||
|
||||
/// The persisted index as a restarted page loads it: one stored thumbnail.
|
||||
fn persisted_index(key: Hash128) -> (ThumbIndex, String) {
|
||||
let mut index = ThumbIndex::default();
|
||||
let hex = index.cache_key(key);
|
||||
index.insert(&hex, 100);
|
||||
(ThumbIndex::from_json(&index.to_json()), hex)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_previews_before_the_first_request_invalidates_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let cache = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&cache);
|
||||
let mut gone = Vec::new();
|
||||
// each frame (`Workers::finished`) observes the watched cache, even with no requests
|
||||
let mut frame = |watch: &mut CacheWatch, index: &mut ThumbIndex| {
|
||||
if let Some(c) = watch.cache() {
|
||||
gone.extend(watch.observe(&c, c.generation(), index));
|
||||
}
|
||||
};
|
||||
frame(&mut watch, &mut index); // an empty filtered view: no render request yet
|
||||
cache.clear(); // File ▸ Clear Preview Cache
|
||||
frame(&mut watch, &mut index);
|
||||
// the filter is removed: the first thumbnail request (`Workers::try_start`)
|
||||
gone.extend(watch.observe(&cache, cache.generation(), &mut index));
|
||||
let disk_key = index.cache_key(key);
|
||||
assert!(!index.touch(&disk_key), "the pre-clear thumbnail must not be read back");
|
||||
assert_eq!(gone, vec![old.clone()], "the stored file is deleted");
|
||||
assert_ne!(disk_key, old, "a fresh namespace: a late old write can't come back");
|
||||
assert!(index.dirty, "the invalidated index is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_cache_before_the_first_request_invalidates_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let first = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&first);
|
||||
let other = Arc::new(PreviewCache::memory(1 << 20)); // another library, generation 0 again
|
||||
assert_eq!(watch.observe(&other, other.generation(), &mut index), vec![old]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_start_keeps_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let cache = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&cache);
|
||||
for _ in 0..3 {
|
||||
assert!(watch.observe(&cache, cache.generation(), &mut index).is_empty());
|
||||
}
|
||||
assert_eq!(index.cache_key(key), old);
|
||||
assert!(index.touch(&old), "persisted thumbnails survive a restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
|
||||
/// Storage key of the thumbnail index.
|
||||
pub const THUMB_INDEX: &str = "thumbs/index.json";
|
||||
@@ -155,6 +155,10 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
// main side
|
||||
|
||||
struct Busy {
|
||||
disk_key: Option<String>,
|
||||
namespace: u64,
|
||||
request_id: u64,
|
||||
cache_generation: u64,
|
||||
id: u32,
|
||||
slot: Slot,
|
||||
photo: PhotoId,
|
||||
@@ -171,6 +175,8 @@ struct W {
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// The rendered-thumbnail cache the stored thumbnails belong to.
|
||||
preview_generation: CacheWatch,
|
||||
workers: Vec<W>,
|
||||
done: Vec<(Slot, RenderResult, f64)>,
|
||||
next_id: u32,
|
||||
@@ -185,14 +191,32 @@ struct Inner {
|
||||
inline_this_frame: bool,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn observe_cache(&mut self, cache: &Arc<PreviewCache>, generation: u64) {
|
||||
let gone = self.preview_generation.observe(cache, generation, &mut self.index);
|
||||
if !gone.is_empty()
|
||||
&& let Some(backend) = self.backend.clone()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for key in gone {
|
||||
let _ = backend.remove(&thumb_storage_key(&key)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main thread's handle on the render workers.
|
||||
#[derive(Clone)]
|
||||
pub struct Workers(Rc<RefCell<Inner>>);
|
||||
|
||||
impl Workers {
|
||||
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, ctx: egui::Context) -> Workers {
|
||||
/// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
|
||||
/// now: watching it from the start, a clear before the first render request still counts.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, cache: &Arc<PreviewCache>, ctx: egui::Context) -> Workers {
|
||||
let w = Workers(Rc::new(RefCell::new(Inner {
|
||||
preview_generation: CacheWatch::new(cache),
|
||||
workers: Vec::new(),
|
||||
done: Vec::new(),
|
||||
next_id: 0,
|
||||
@@ -248,7 +272,16 @@ impl Workers {
|
||||
w.dead = true;
|
||||
w.worker.terminate();
|
||||
if let Some(b) = w.busy.take() {
|
||||
let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None, quick: None };
|
||||
let r = RenderResult {
|
||||
request_id: b.request_id,
|
||||
source_key: None,
|
||||
photo: b.photo,
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered: Err(msg),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
g.done.push((b.slot, r, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -284,9 +317,11 @@ impl Workers {
|
||||
};
|
||||
if let Ok(r) = &rendered
|
||||
&& let Some((cache, key)) = &b.cache
|
||||
&& cache.generation() == b.cache_generation
|
||||
&& b.namespace == g.index.namespace()
|
||||
{
|
||||
cache.put(*key, Arc::new(r.image.clone()));
|
||||
let hex = key.to_string();
|
||||
cache.put_at(b.cache_generation, *key, Arc::new(r.image.clone()));
|
||||
let hex = b.disk_key.clone().unwrap_or_else(|| key.to_string());
|
||||
let stored = get(&data, "stored").as_f64().unwrap_or(0.0) as u64;
|
||||
if get(&data, "cacheMiss").is_truthy() {
|
||||
g.index.remove(&hex);
|
||||
@@ -307,8 +342,31 @@ impl Workers {
|
||||
}
|
||||
}
|
||||
g.affinity.insert((b.photo, b.level), i);
|
||||
// A cleared worker may finish writing after the initial deletion. Its old namespace
|
||||
// is never reused, so removing this file cannot delete a replacement's cache entry.
|
||||
if let Some((cache, _)) = &b.cache
|
||||
&& (cache.generation() != b.cache_generation || b.namespace != g.index.namespace())
|
||||
&& let (Some(backend), Some(key)) = (g.backend.clone(), b.disk_key.clone())
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = backend.remove(&thumb_storage_key(&key)).await;
|
||||
});
|
||||
}
|
||||
g.remote_done += 1;
|
||||
g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None, quick: None }, ms));
|
||||
g.done.push((
|
||||
b.slot,
|
||||
RenderResult {
|
||||
request_id: b.request_id,
|
||||
source_key: None,
|
||||
photo: b.photo,
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
},
|
||||
ms,
|
||||
));
|
||||
}
|
||||
|
||||
/// The thumbnail index as JSON if it changed since the last call (the host saves it).
|
||||
@@ -334,13 +392,18 @@ impl RenderOffload for Workers {
|
||||
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
let g = &mut *g;
|
||||
if let Some((cache, _)) = job.cache.as_ref().or(job.view_cache.as_ref()) {
|
||||
g.observe_cache(cache, job.cache_generation);
|
||||
}
|
||||
// rendered thumbnail in memory: no worker needed
|
||||
if let Some((cache, key)) = &job.cache
|
||||
&& let Some(img) = cache.get(*key)
|
||||
&& let Some(img) = cache.get_at(job.cache_generation, *key)
|
||||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
let r = RenderResult {
|
||||
request_id: job.request_id,
|
||||
source_key: job.source_key,
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
@@ -374,25 +437,45 @@ impl RenderOffload for Workers {
|
||||
let Some(i) = pick else { return Some(job) };
|
||||
// decided here, so the next job for this photo follows even before this one finishes
|
||||
g.affinity.insert((job.photo, job.level), i);
|
||||
let thumb_cached = job.cache.as_ref().is_some_and(|(_, k)| g.index.touch(&k.to_string()));
|
||||
let wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
let disk_key = job.cache.as_ref().map(|(_, key)| g.index.cache_key(*key));
|
||||
let thumb_cached = disk_key.as_ref().is_some_and(|key| g.index.touch(key));
|
||||
let mut wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
// The namespace also changes when a new library's cache starts again at generation zero.
|
||||
wire.cache_generation = g.index.namespace();
|
||||
wire.thumb = disk_key.clone();
|
||||
let Ok(text) = serde_json::to_string(&wire) else { return Some(job) };
|
||||
g.next_id = g.next_id.wrapping_add(1);
|
||||
let id = g.next_id;
|
||||
let msg = Object::new();
|
||||
set(&msg, "id", &id.into());
|
||||
set(&msg, "job", &text.into());
|
||||
let namespace = g.index.namespace();
|
||||
let w = &mut g.workers[i];
|
||||
if let Err(e) = w.worker.post_message(&msg) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
return Some(job);
|
||||
}
|
||||
w.busy = Some(Busy { id, slot, photo: job.photo, level: job.level, key: job.key, cache: job.cache });
|
||||
w.busy = Some(Busy {
|
||||
disk_key,
|
||||
namespace,
|
||||
request_id: job.request_id,
|
||||
cache_generation: job.cache_generation,
|
||||
id,
|
||||
slot,
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
cache: job.cache,
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
// Persist invalidation even when the cleared library has no visible render requests.
|
||||
if let Some(cache) = g.preview_generation.cache() {
|
||||
g.observe_cache(&cache, cache.generation());
|
||||
}
|
||||
g.inline_this_frame = false;
|
||||
std::mem::take(&mut g.done)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ default-run = "lightcraft"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-engine = { workspace = true, features = ["sam"] }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
+144
-25
@@ -71,17 +71,45 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
/// 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> {
|
||||
lightcraft_engine::camera_profiles::config_dir()
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
||||
@@ -202,7 +230,9 @@ impl PrefsWriter {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
open_with: Some(Box::new(|path: &str, app: &str| {
|
||||
// spawned, never waited for: the editor runs alongside
|
||||
let app = app.trim();
|
||||
@@ -254,10 +284,10 @@ fn services() -> Services {
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Photos",
|
||||
lightcraft_ui_egui::i18n::tr("Photos"),
|
||||
&[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
|
||||
"jxl", "gif", "bmp",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
|
||||
"pef", "psd", "jxl", "gif", "bmp",
|
||||
],
|
||||
)
|
||||
.pick_files()
|
||||
@@ -268,8 +298,11 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
||||
)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -278,24 +311,24 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Presets")
|
||||
.add_filter("LightCraft Preset", &["lcpreset"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -304,8 +337,8 @@ fn services() -> Services {
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -316,6 +349,9 @@ fn services() -> Services {
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
// the library is a folder on disk: backed up with the user's other files
|
||||
backup_library: None,
|
||||
restore_library: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,10 +402,46 @@ 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.
|
||||
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
optional: LightCraft offers to download it when first needed)
|
||||
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
||||
";
|
||||
|
||||
/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info`
|
||||
/// (or `debug`) shows more.
|
||||
struct StderrLog(log::LevelFilter);
|
||||
|
||||
impl log::Log for StderrLog {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft"))
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn install_log() {
|
||||
let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() {
|
||||
"debug" => log::LevelFilter::Debug,
|
||||
"info" => log::LevelFilter::Info,
|
||||
_ => log::LevelFilter::Warn,
|
||||
};
|
||||
static LOGGER: std::sync::OnceLock<StderrLog> = std::sync::OnceLock::new();
|
||||
if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() {
|
||||
log::set_max_level(level);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
install_log();
|
||||
alloc_release::install();
|
||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
@@ -408,6 +480,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")
|
||||
@@ -420,17 +497,25 @@ 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(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir =
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
@@ -440,6 +525,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);
|
||||
@@ -497,6 +587,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");
|
||||
|
||||
@@ -148,7 +148,7 @@ fn yields_to_text(sc: &str) -> bool {
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn label_text(s: &str) -> String {
|
||||
s.replace('&', "&&")
|
||||
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
|
||||
}
|
||||
|
||||
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
||||
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
||||
out.push(';');
|
||||
}
|
||||
}
|
||||
let mut s = String::new();
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
|
||||
for (t, items) in bar {
|
||||
s.push_str(t);
|
||||
walk(items, &mut s);
|
||||
@@ -210,30 +210,30 @@ impl NativeMenu {
|
||||
|
||||
// the application menu
|
||||
let app_menu = Submenu::new("LightCraft", true);
|
||||
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
|
||||
let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))),
|
||||
&PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))),
|
||||
&PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&quit,
|
||||
]);
|
||||
let _ = self.menu.append(&app_menu);
|
||||
|
||||
for (title, nodes) in &bar {
|
||||
let sub = Submenu::new(title, true);
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let sub = Submenu::new(label_text(title), true);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(Some("Zoom")),
|
||||
&PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))),
|
||||
&PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
]);
|
||||
}
|
||||
@@ -245,15 +245,21 @@ impl NativeMenu {
|
||||
.collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == app.ui.language.tr("File") {
|
||||
if title == "File" {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
|
||||
]);
|
||||
}
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))),
|
||||
]);
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
if title == app.ui.language.tr("Help") {
|
||||
if title == "Help" {
|
||||
sub.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
let _ = self.menu.append(&sub);
|
||||
@@ -282,12 +288,23 @@ impl NativeMenu {
|
||||
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
|
||||
let handle = match checked {
|
||||
Some(c) => {
|
||||
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
|
||||
let it = CheckMenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
*c,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Check(it)
|
||||
}
|
||||
None => {
|
||||
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
|
||||
let it = MenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Plain(it)
|
||||
}
|
||||
@@ -325,6 +342,7 @@ impl NativeMenu {
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -352,8 +370,8 @@ impl NativeMenu {
|
||||
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
|
||||
if it.label != *label {
|
||||
match &it.handle {
|
||||
Handle::Plain(h) => h.set_text(label_text(label)),
|
||||
Handle::Check(h) => h.set_text(label_text(label)),
|
||||
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
}
|
||||
it.label = label.clone();
|
||||
}
|
||||
@@ -401,6 +419,27 @@ impl NativeMenu {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The menu model's titles stay English, so only the language can tell the native menu to
|
||||
/// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
|
||||
/// languages — must change the structure key.
|
||||
#[test]
|
||||
fn switching_language_rebuilds_native_menu_structure() {
|
||||
use lightcraft_ui_egui::i18n::{Locale, set_language};
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
set_language(Locale::En);
|
||||
let bar = menu_bar(&app);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for language in Locale::ALL {
|
||||
set_language(*language);
|
||||
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
|
||||
}
|
||||
set_language(Locale::ZhHant);
|
||||
assert_eq!(label_text("File"), "檔案");
|
||||
assert_eq!(label_text("Settings…"), "設定…");
|
||||
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_map_to_accelerators() {
|
||||
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
|
||||
|
||||
@@ -9,9 +9,8 @@ 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` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `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 |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
@@ -20,7 +19,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 |
|
||||
| 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`).
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"model":"ILCE-7M4","files":597,"samples":2182876,"matrix":[[1.06975,-0.0709886,-0.000770444],[-0.0169822,1.08012,-0.0651276],[-0.114835,-0.601525,1.74804]],"hue_sat":{"hue_divisions":72,"sat_divisions":5,"val_divisions":5,"data":[[0.0,1.0,1.0],[-0.819008,1.14375,1.0],[-6.07446,1.10974,1.0],[-4.0478,1.04285,1.0],[-0.546764,1.00329,1.0],[0.0,1.0,1.0],[-1.0412,1.10597,1.0],[-7.22645,1.05878,1.0],[-7.30635,0.989387,1.0],[-5.18589,0.974522,1.0],[0.0,1.0,1.0],[-1.02364,1.06303,1.0],[-6.94669,1.01579,1.0],[-7.47614,0.955435,1.0],[-7.02579,0.940854,1.0],[0.0,1.0,1.0],[-0.29852,1.04349,1.0],[-6.37202,1.00021,1.0],[-7.52098,0.935457,1.0],[-7.45702,0.922239,1.0],[0.0,1.0,1.0],[0.367464,1.03266,1.0],[-4.96951,0.996822,1.0],[-7.41706,0.948709,1.0],[-8.04735,0.91672,1.0],[0.0,1.0,1.0],[0.0388419,1.02587,1.0],[-4.47266,1.01282,1.0],[-7.26834,0.987279,1.0],[-9.02713,0.921453,1.0],[0.0,1.0,1.0],[-0.297476,1.02333,1.0],[-4.6323,1.00993,1.0],[-8.57248,0.962973,1.0],[-12.2375,0.872088,1.0],[0.0,1.0,1.0],[-0.785867,1.02786,1.0],[-4.73124,1.00876,1.0],[-9.09856,0.955343,1.0],[-12.4324,0.875472,1.0],[0.0,1.0,1.0],[-0.444072,1.03863,1.0],[-3.94773,1.01784,1.0],[-7.97996,0.975996,1.0],[-10.673,0.917401,1.0],[0.0,1.0,1.0],[1.22651,1.0559,1.0],[-1.89459,1.02934,1.0],[-5.54545,0.993046,1.0],[-8.48877,0.941628,1.0],[0.0,1.0,1.0],[3.24516,1.08292,1.0],[1.20522,1.0577,1.0],[-0.826224,1.01668,1.0],[-3.71884,0.946733,1.0],[0.0,1.0,1.0],[4.68802,1.12039,1.0],[3.23147,1.08619,1.0],[1.84476,1.04,1.0],[0.0970375,0.962298,1.0],[0.0,1.0,1.0],[5.36041,1.16313,1.0],[4.5937,1.10933,1.0],[3.83314,1.05056,1.0],[3.122,0.959876,1.0],[0.0,1.0,1.0],[5.22222,1.17824,1.0],[5.09129,1.1142,1.0],[4.99294,1.04813,1.0],[5.01485,0.957528,1.0],[0.0,1.0,1.0],[4.34382,1.18446,1.0],[4.9457,1.11351,1.0],[5.56715,1.04462,1.0],[6.27579,0.961892,1.0],[0.0,1.0,1.0],[3.5428,1.18985,1.0],[4.9116,1.11394,1.0],[6.11507,1.04098,1.0],[7.03638,0.964331,1.0],[0.0,1.0,1.0],[2.92641,1.20663,1.0],[4.57132,1.1281,1.0],[5.96964,1.04895,1.0],[6.98579,0.96261,1.0],[0.0,1.0,1.0],[2.07123,1.23257,1.0],[3.51736,1.15454,1.0],[4.85051,1.06995,1.0],[5.97805,0.971597,1.0],[0.0,1.0,1.0],[0.869122,1.26762,1.0],[2.22009,1.19021,1.0],[3.54297,1.10536,1.0],[4.31783,1.00254,1.0],[0.0,1.0,1.0],[-0.409355,1.31042,1.0],[0.778039,1.23845,1.0],[1.87845,1.15455,1.0],[1.53759,1.02961,1.0],[0.0,1.0,1.0],[-1.82067,1.35265,1.0],[-0.910751,1.28551,1.0],[-0.100378,1.18585,1.0],[0.0425375,1.01463,1.0],[0.0,1.0,1.0],[-3.26659,1.391,1.0],[-2.50117,1.33263,1.0],[-1.55789,1.18102,1.0],[-0.0853484,1.00594,1.0],[0.0,1.0,1.0],[-4.92446,1.43632,1.0],[-3.92066,1.35798,1.0],[-1.66274,1.11605,1.0],[-0.0358972,1.00178,1.0],[0.0,1.0,1.0],[-5.82474,1.46137,1.0],[-4.22466,1.34716,1.0],[-0.64525,1.04813,1.0],[-0.00357121,1.00023,1.0],[0.0,1.0,1.0],[-7.0672,1.47242,1.0],[-5.38667,1.32663,1.0],[-0.553542,1.02668,1.0],[-0.00188478,1.00008,1.0],[0.0,1.0,1.0],[-8.37522,1.43136,1.0],[-6.13631,1.28174,1.0],[-0.515332,1.01808,1.0],[-0.0015762,1.00005,1.0],[0.0,1.0,1.0],[-10.5196,1.39935,1.0],[-7.49316,1.24442,1.0],[-0.517621,1.01282,1.0],[-0.001445,1.00003,1.0],[0.0,1.0,1.0],[-10.5952,1.3534,1.0],[-7.81548,1.20414,1.0],[-0.63473,1.0111,1.0],[-0.00208267,1.00003,1.0],[0.0,1.0,1.0],[-10.879,1.32051,1.0],[-8.3374,1.18517,1.0],[-0.91817,1.01139,1.0],[-0.00474972,1.00003,1.0],[0.0,1.0,1.0],[-11.8094,1.29525,1.0],[-9.18818,1.17522,1.0],[-0.968552,1.01157,1.0],[-0.00411555,1.00003,1.0],[0.0,1.0,1.0],[-12.2038,1.27777,1.0],[-8.97374,1.15856,1.0],[-0.751018,1.00872,1.0],[-0.00250012,1.00002,1.0],[0.0,1.0,1.0],[-12.0564,1.2709,1.0],[-8.38288,1.15926,1.0],[-0.643806,1.00878,1.0],[-0.00202184,1.00002,1.0],[0.0,1.0,1.0],[-12.8403,1.25618,1.0],[-8.85664,1.14517,1.0],[-0.646111,1.00756,1.0],[-0.00195373,1.00001,1.0],[0.0,1.0,1.0],[-13.2604,1.25094,1.0],[-8.74013,1.11989,1.0],[-0.556009,1.00457,1.0],[-0.00173404,1.00001,1.0],[0.0,1.0,1.0],[-13.3939,1.24388,1.0],[-8.23916,1.11659,1.0],[-0.376536,1.00355,1.0],[-0.000916476,1.00001,1.0],[0.0,1.0,1.0],[-13.6578,1.24212,1.0],[-7.97883,1.11035,1.0],[-0.409859,1.00287,1.0],[-0.0018203,1.0,1.0],[0.0,1.0,1.0],[-15.7225,1.22414,1.0],[-10.2345,1.10217,1.0],[-0.968177,1.00427,1.0],[-0.00650675,1.00001,1.0],[0.0,1.0,1.0],[-16.1557,1.16423,1.0],[-12.721,1.06603,1.0],[-2.02337,1.00334,1.0],[-0.0134173,1.00001,1.0],[0.0,1.0,1.0],[-17.253,1.09714,1.0],[-15.785,1.02184,1.0],[-4.02264,0.996649,1.0],[-0.0279292,0.999956,1.0],[0.0,1.0,1.0],[-17.6206,1.04331,1.0],[-16.2039,0.99401,1.0],[-4.09728,0.990246,1.0],[-0.0269801,0.999896,1.0],[0.0,1.0,1.0],[-17.2302,0.987569,1.0],[-15.1656,0.964114,1.0],[-3.21398,0.983725,1.0],[-0.0202065,0.999807,1.0],[0.0,1.0,1.0],[-16.2184,0.943678,1.0],[-13.4633,0.938141,1.0],[-3.40639,0.971571,1.0],[-0.00923182,0.999563,1.0],[0.0,1.0,1.0],[-14.5653,0.909699,1.0],[-11.6873,0.915733,1.0],[-5.4654,0.942337,1.0],[-0.124331,0.998194,1.0],[0.0,1.0,1.0],[-11.5122,0.906891,1.0],[-9.31547,0.923874,1.0],[-6.12222,0.939832,1.0],[-0.337538,0.995963,1.0],[0.0,1.0,1.0],[-7.77619,0.92294,1.0]Line truncated
|
||||
Binary file not shown.
@@ -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.
|
||||
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/lightcraft",
|
||||
"branch": "main",
|
||||
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
|
||||
"head_date": "2026-10-08T00:53:19Z",
|
||||
"commits": 424,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 71,
|
||||
"commits": 330,
|
||||
"lines_added": 125623,
|
||||
"lines_deleted": 8643,
|
||||
"binary_added": 38,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T14:04:12Z",
|
||||
"last_commit": "2026-10-08T00:32:34Z"
|
||||
},
|
||||
{
|
||||
"login": "leipsfur",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 131,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T15:51:58Z",
|
||||
"last_commit": "2026-10-02T15:51:58Z"
|
||||
},
|
||||
{
|
||||
"login": "GlemNL",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 13,
|
||||
"lines_added": 1233,
|
||||
"lines_deleted": 96,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T21:16:29Z",
|
||||
"last_commit": "2026-10-04T09:42:39Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 1194,
|
||||
"lines_deleted": 82,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T00:28:49Z",
|
||||
"last_commit": "2026-10-06T06:51:43Z"
|
||||
},
|
||||
{
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 424,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 1,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:07:26Z",
|
||||
"last_commit": "2026-10-05T16:07:26Z"
|
||||
},
|
||||
{
|
||||
"login": "voshart",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 12,
|
||||
"lines_added": 2481,
|
||||
"lines_deleted": 1389,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:23:27Z",
|
||||
"last_commit": "2026-10-07T16:07:24Z"
|
||||
},
|
||||
{
|
||||
"login": "arata-1972",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 4,
|
||||
"lines_added": 2892,
|
||||
"lines_deleted": 726,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T20:14:35Z",
|
||||
"last_commit": "2026-10-05T22:16:15Z"
|
||||
},
|
||||
{
|
||||
"login": "benlukka",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 49,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:18:03Z",
|
||||
"last_commit": "2026-10-06T11:18:03Z"
|
||||
},
|
||||
{
|
||||
"login": "kywk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 72,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T20:21:14Z",
|
||||
"last_commit": "2026-10-06T20:21:14Z"
|
||||
},
|
||||
{
|
||||
"login": "treyreynolds",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 159,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T22:21:46Z",
|
||||
"last_commit": "2026-10-06T22:21:46Z"
|
||||
},
|
||||
{
|
||||
"login": "kovalskiguinther",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 4328,
|
||||
"lines_deleted": 17,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T23:15:04Z",
|
||||
"last_commit": "2026-10-06T23:15:04Z"
|
||||
},
|
||||
{
|
||||
"login": "cruzer45",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 658,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T01:30:23Z",
|
||||
"last_commit": "2026-10-07T03:53:09Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 4,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T02:27:15Z",
|
||||
"last_commit": "2026-10-07T02:27:15Z"
|
||||
},
|
||||
{
|
||||
"login": "lzjtugjc",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:33Z",
|
||||
"last_commit": "2026-10-07T03:34:33Z"
|
||||
},
|
||||
{
|
||||
"login": "itsmunzir",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 46,
|
||||
"lines_deleted": 30,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T07:40:55Z",
|
||||
"last_commit": "2026-10-07T07:40:55Z"
|
||||
},
|
||||
{
|
||||
"login": "haraldh",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 5,
|
||||
"lines_added": 1163,
|
||||
"lines_deleted": 141,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T07:54:27Z",
|
||||
"last_commit": "2026-10-07T13:16:08Z"
|
||||
},
|
||||
{
|
||||
"login": "sakamoto824",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 3649,
|
||||
"lines_deleted": 167,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T09:32:12Z",
|
||||
"last_commit": "2026-10-07T10:54:19Z"
|
||||
},
|
||||
{
|
||||
"login": "anarion80",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 402,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:54:51Z",
|
||||
"last_commit": "2026-10-07T11:54:51Z"
|
||||
},
|
||||
{
|
||||
"login": "radialmonster",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 19,
|
||||
"lines_added": 2630,
|
||||
"lines_deleted": 259,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T12:33:51Z",
|
||||
"last_commit": "2026-10-08T00:46:10Z"
|
||||
},
|
||||
{
|
||||
"login": "bogusyogi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 434,
|
||||
"lines_deleted": 29,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:06:00Z",
|
||||
"last_commit": "2026-10-07T17:06:00Z"
|
||||
},
|
||||
{
|
||||
"login": "nledford",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 8,
|
||||
"lines_added": 525,
|
||||
"lines_deleted": 60,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:33:38Z",
|
||||
"last_commit": "2026-10-07T22:49:00Z"
|
||||
},
|
||||
{
|
||||
"login": "MusseJusse",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T18:38:23Z",
|
||||
"last_commit": "2026-10-07T18:38:23Z"
|
||||
},
|
||||
{
|
||||
"login": "Ndsanes",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 332,
|
||||
"lines_deleted": 86,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T19:08:28Z",
|
||||
"last_commit": "2026-10-07T19:08:33Z"
|
||||
},
|
||||
{
|
||||
"login": "RicSchonfelder",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1439,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:03:20Z",
|
||||
"last_commit": "2026-10-07T20:03:20Z"
|
||||
},
|
||||
{
|
||||
"login": "Gremiger",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 19,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T21:43:23Z",
|
||||
"last_commit": "2026-10-07T21:43:23Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 54,
|
||||
"lines_deleted": 12,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:08:01Z",
|
||||
"last_commit": "2026-10-07T22:08:01Z"
|
||||
},
|
||||
{
|
||||
"login": "agorevski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 11,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:18:44Z",
|
||||
"last_commit": "2026-10-07T22:18:44Z"
|
||||
},
|
||||
{
|
||||
"login": "andrewyao",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T00:53:19Z",
|
||||
"last_commit": "2026-10-08T00:53:19Z"
|
||||
},
|
||||
{
|
||||
"login": "GuintherKovalski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "XRTce",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 364,
|
||||
"lines_added": 131810,
|
||||
"lines_deleted": 9367
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 31,
|
||||
"lines_added": 3941,
|
||||
"lines_deleted": 1543
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 14,
|
||||
"lines_added": 8341,
|
||||
"lines_deleted": 995
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 2,
|
||||
"lines_added": 1304,
|
||||
"lines_deleted": 192
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5",
|
||||
"commits": 2,
|
||||
"lines_added": 298,
|
||||
"lines_deleted": 2
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 1,
|
||||
"lines_added": 1633,
|
||||
"lines_deleted": 3
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek/v3.2",
|
||||
"company": "DeepSeek",
|
||||
"model": "DeepSeek",
|
||||
"version": "V3.2",
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -35,7 +35,7 @@ use lightcraft_develop::DevelopSettings;
|
||||
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
|
||||
pub use lock::{LibraryLock, LockError, LockOwner};
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::{FsStore, MemStore, Store};
|
||||
|
||||
@@ -183,6 +183,11 @@ pub struct Meta {
|
||||
pub copyright_url: String,
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
/// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo.
|
||||
/// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP.
|
||||
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub regions: Vec<lightcraft_meta::Region>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -331,6 +336,13 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
@@ -342,7 +354,8 @@ impl Photo {
|
||||
/// The built-in defaults for this photo, before any user default preset: raws start from
|
||||
/// their as-shot white balance; embedded lens corrections on when the file has them.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let mut d = match self.as_shot_wb {
|
||||
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let mut d = match wb {
|
||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||
None => DevelopSettings::default(),
|
||||
};
|
||||
@@ -415,4 +428,27 @@ mod edited_tests {
|
||||
p.develop = Arc::new(DevelopSettings::default());
|
||||
assert!(!p.is_edited(), "a full reset is unedited too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_and_nikon_raws_use_relative_white_balance() {
|
||||
for (name, format, relative) in [
|
||||
("a.arw", "ARW", true),
|
||||
("a.nef", "NEF", true),
|
||||
("a.nrw", "nrw", true),
|
||||
("a.rw2", "RW2", true),
|
||||
("a.rwl", "RWL", true),
|
||||
("a.raw", "RAW", true),
|
||||
("a.dng", "DNG", false),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
assert_eq!(p.relative_wb(), relative, "{format}");
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
|
||||
// shown from the embedded preview: a rendered image, not a relative-WB raw
|
||||
p.preview_only = Some("unsupported".into());
|
||||
assert!(!p.relative_wb());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ pub struct Filter {
|
||||
pub date: Option<String>,
|
||||
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
|
||||
pub keyword: Option<String>,
|
||||
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
|
||||
pub person: Option<String>,
|
||||
pub camera: Option<String>,
|
||||
/// Lens (case-insensitive substring).
|
||||
pub lens: Option<String>,
|
||||
@@ -70,6 +72,9 @@ pub enum SortKey {
|
||||
FileName,
|
||||
Rating,
|
||||
FileSize,
|
||||
/// A shuffle fixed by [`Sort::seed`]: the same seed always gives the same order, and photos
|
||||
/// added or edited later never reshuffle the others. Pick a new seed to reshuffle.
|
||||
Random,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -79,14 +84,30 @@ pub struct Sort {
|
||||
pub ascending: bool,
|
||||
/// Date headers in the grid (date sort keys only).
|
||||
pub group: crate::GroupBy,
|
||||
/// Which shuffle [`SortKey::Random`] gives (ignored by the other keys).
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for Sort {
|
||||
fn default() -> Self {
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto }
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// splitmix64 finaliser: a stateless, platform-independent mix (no RNG state, no `rand` version
|
||||
/// to drift), so a seed names the same shuffle on every machine.
|
||||
pub fn mix64(x: u64) -> u64 {
|
||||
let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A photo's place in the shuffle for `seed`.
|
||||
fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
|
||||
mix64(mix64(seed) ^ id.0)
|
||||
}
|
||||
|
||||
fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
let t = tok.to_lowercase();
|
||||
if let Some((field, val)) = t.split_once(':') {
|
||||
@@ -113,6 +134,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
"camera" => p.meta.camera.to_lowercase().contains(val),
|
||||
"lens" => p.meta.lens.to_lowercase().contains(val),
|
||||
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
|
||||
"person" | "who" => has_person(p, val),
|
||||
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
|
||||
"edited" => (val == "true" || val == "yes") == p.is_edited(),
|
||||
"date" => p.date().starts_with(val),
|
||||
@@ -125,6 +147,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
|
||||
}
|
||||
|
||||
/// Whether `p` has a named face region called `name` (case-insensitive).
|
||||
fn has_person(p: &Photo, name: &str) -> bool {
|
||||
let name = name.trim().to_lowercase();
|
||||
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
|
||||
pub fn describe(&self) -> String {
|
||||
@@ -152,9 +180,14 @@ impl Filter {
|
||||
if let Some(e) = self.edited {
|
||||
v.push(if e { "edited".into() } else { "unedited".into() });
|
||||
}
|
||||
for (name, val) in
|
||||
[("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)]
|
||||
{
|
||||
for (name, val) in [
|
||||
("keyword", &self.keyword),
|
||||
("person", &self.person),
|
||||
("camera", &self.camera),
|
||||
("lens", &self.lens),
|
||||
("date", &self.date),
|
||||
("imported", &self.imported),
|
||||
] {
|
||||
if let Some(x) = val {
|
||||
v.push(format!("{name} {x}"));
|
||||
}
|
||||
@@ -267,6 +300,11 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(n) = &self.person
|
||||
&& !has_person(p, n)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(c) = &self.camera
|
||||
&& !p.meta.camera.eq_ignore_ascii_case(c)
|
||||
{
|
||||
@@ -288,6 +326,7 @@ impl Catalog {
|
||||
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
|
||||
SortKey::Rating => a.rating.cmp(&b.rating),
|
||||
SortKey::FileSize => a.file_size.cmp(&b.file_size),
|
||||
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
|
||||
}
|
||||
.then(a.id.cmp(&b.id));
|
||||
if sort.ascending { o } else { o.reverse() }
|
||||
@@ -330,6 +369,54 @@ impl Catalog {
|
||||
}
|
||||
m.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The people named on faces in the library (MWG regions read from XMP): how many photos each
|
||||
/// appears in and the photo showing their largest face (for a card's picture). Most photos first,
|
||||
/// then by name. Names that differ only in case are one person, shown as first seen; a person
|
||||
/// twice in one photo counts once.
|
||||
pub fn people(&self) -> Vec<Person> {
|
||||
self.people_in(&Filter::default())
|
||||
}
|
||||
|
||||
/// [`Self::people`] among the photos `filter` lets through (its `person` is ignored): what the
|
||||
/// People view offers while other filters (a date, a rating, an album…) are active, so picking
|
||||
/// a person never ends in an empty grid.
|
||||
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
|
||||
let filter = Filter { person: None, ..filter.clone() };
|
||||
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
|
||||
for p in self.photos().filter(|p| filter.matches(p, self)) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
|
||||
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
let key = name.to_lowercase();
|
||||
// the face's size in pixels of the photo
|
||||
let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64;
|
||||
let (person, best) =
|
||||
m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area));
|
||||
if !seen.contains(&key) {
|
||||
person.count += 1;
|
||||
seen.push(key);
|
||||
}
|
||||
if area > *best || (area == *best && p.id < person.photo) {
|
||||
(person.photo, person.face, *best) = (p.id, r.rect, area);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Person> = m.into_values().map(|(p, _)| p).collect();
|
||||
v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// A person named on faces in the library ([`Catalog::people`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Person {
|
||||
pub name: String,
|
||||
/// Photos they appear in.
|
||||
pub count: usize,
|
||||
/// The photo with their largest face, and that face (normalized, in the photo's upright frame).
|
||||
pub photo: PhotoId,
|
||||
pub face: lightcraft_meta::Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Catalog, Photo};
|
||||
use crate::{Catalog, Photo, Source};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -62,6 +62,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("keywords", "Keywords", Kind::Keywords),
|
||||
("text", "Any Searchable Text", Kind::Text),
|
||||
("fileName", "Filename", Kind::Text),
|
||||
("filePath", "File Path", Kind::Text),
|
||||
("format", "File Format", Kind::Text),
|
||||
("title", "Title", Kind::Text),
|
||||
("caption", "Caption", Kind::Text),
|
||||
@@ -304,6 +305,20 @@ impl Rule {
|
||||
text(&all)
|
||||
}
|
||||
"fileName" => text(&p.file_name),
|
||||
"filePath" => {
|
||||
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
|
||||
let path = match &p.source {
|
||||
Source::File { path } => path.replace('\\', "/"),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
let want = want.replace('\\', "/");
|
||||
match op {
|
||||
// the whole string, spaces included (not word by word like the other text fields)
|
||||
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
|
||||
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
|
||||
_ => text_op(op, &path, &want),
|
||||
}
|
||||
}
|
||||
"format" => text(&p.format),
|
||||
"title" => text(&m.title),
|
||||
"caption" => text(&m.caption),
|
||||
@@ -445,6 +460,17 @@ mod tests {
|
||||
);
|
||||
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
|
||||
// file path: the whole string (spaces included), either separator, case-insensitive
|
||||
let mut fp = photo(3);
|
||||
fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() };
|
||||
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]})));
|
||||
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]})));
|
||||
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]})));
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
|
||||
no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path
|
||||
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
|
||||
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -297,3 +297,152 @@ fn folder_identity_ignores_spelling() {
|
||||
assert!(folder_within("/a/b", "/"));
|
||||
assert!(folder_within("C:\\x", "c:\\"));
|
||||
}
|
||||
|
||||
/// People come from named *face* regions: counted once per photo, case-insensitive, most photos
|
||||
/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match.
|
||||
#[test]
|
||||
fn people_from_named_face_regions() {
|
||||
use lightcraft_meta::{Rect, Region, RegionKind};
|
||||
let region = |name: Option<&str>, kind: RegionKind| Region {
|
||||
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
|
||||
kind,
|
||||
name: name.map(str::to_string),
|
||||
description: None,
|
||||
};
|
||||
let mut c = Catalog::new();
|
||||
let mut add = |name: &str, regions: Vec<Region>| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
|
||||
p.meta.regions = regions;
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let a = add(
|
||||
"a.jpg",
|
||||
vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)],
|
||||
);
|
||||
let b =
|
||||
add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]);
|
||||
let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 };
|
||||
let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]);
|
||||
let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]);
|
||||
add("f.jpg", vec![]);
|
||||
let people = c.people();
|
||||
let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect();
|
||||
assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out");
|
||||
// the picture is the person's largest face; ties go to the lower photo id
|
||||
assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }));
|
||||
assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one");
|
||||
|
||||
let q = |f: Filter| c.query(&f, &Sort::default());
|
||||
assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2);
|
||||
let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() });
|
||||
got.sort();
|
||||
assert_eq!(got, vec![b, d]);
|
||||
assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person");
|
||||
assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case");
|
||||
assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a));
|
||||
|
||||
// other filters narrow who is offered (so picking a person never ends in an empty grid); the
|
||||
// `person` filter itself does not
|
||||
c.apply(Op::SetRating { id: b, rating: 3 }).unwrap();
|
||||
let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::<Vec<_>>();
|
||||
let rated = Filter { rating: 1, ..Default::default() };
|
||||
assert_eq!(
|
||||
names(rated.clone()),
|
||||
vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)],
|
||||
"only the rated photo's people, counted within it, spelled as first seen there"
|
||||
);
|
||||
assert_eq!(
|
||||
names(Filter { person: Some("Sam".into()), ..rated }),
|
||||
names(Filter { rating: 1, ..Default::default() }),
|
||||
"the person filter is ignored"
|
||||
);
|
||||
assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos");
|
||||
}
|
||||
|
||||
/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written
|
||||
/// before regions existed (no `regions` key) reads with none.
|
||||
#[test]
|
||||
fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
let m = Meta { keywords: vec!["k".into()], ..Default::default() };
|
||||
let v = serde_json::to_value(&m).unwrap();
|
||||
assert!(v.get("regions").is_none(), "{v}");
|
||||
let back: Meta = serde_json::from_value(v).unwrap();
|
||||
assert!(back.regions.is_empty());
|
||||
let mut with = m.clone();
|
||||
with.regions.push(lightcraft_meta::Region {
|
||||
rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
|
||||
kind: lightcraft_meta::RegionKind::Face,
|
||||
name: Some("A".into()),
|
||||
description: None,
|
||||
});
|
||||
let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
|
||||
|
||||
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
|
||||
(0..n).map(|i| photo(c, &format!("p{i:03}.jpg"), &format!("2026-04-{:02}T10:00:00", i % 28 + 1))).collect()
|
||||
}
|
||||
|
||||
fn random(seed: u64) -> Sort {
|
||||
Sort { key: SortKey::Random, seed, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_is_a_reproducible_permutation() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 50);
|
||||
let a = c.query(&Filter::default(), &random(7));
|
||||
assert_eq!(a, c.query(&Filter::default(), &random(7)), "same seed, same order");
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort();
|
||||
let mut all = ids.clone();
|
||||
all.sort();
|
||||
assert_eq!(sorted, all, "every photo exactly once");
|
||||
assert_ne!(a, c.query(&Filter::default(), &random(8)), "another seed, another order");
|
||||
assert_ne!(a, c.query(&Filter::default(), &Sort::default()), "not just the date order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_keeps_the_relative_order_when_the_set_changes() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 30);
|
||||
let before = c.query(&Filter::default(), &random(3));
|
||||
let added = photo(&mut c, "new.jpg", "2026-05-01T10:00:00");
|
||||
c.apply(Op::SetRating { id: ids[0], rating: 5 }).unwrap();
|
||||
let after: Vec<PhotoId> = c.query(&Filter::default(), &random(3)).into_iter().filter(|id| *id != added).collect();
|
||||
assert_eq!(after, before, "adding a photo or editing metadata must not reshuffle the others");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_respects_the_filter_and_handles_tiny_sets() {
|
||||
let mut c = Catalog::new();
|
||||
assert!(c.query(&Filter::default(), &random(1)).is_empty());
|
||||
let ids = many(&mut c, 10);
|
||||
c.apply(Op::SetRating { id: ids[4], rating: 5 }).unwrap();
|
||||
assert_eq!(c.query(&Filter { rating: 5, ..Default::default() }, &random(1)), vec![ids[4]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_direction_reverses_and_serde_defaults_hold() {
|
||||
let mut c = Catalog::new();
|
||||
many(&mut c, 20);
|
||||
let desc = c.query(&Filter::default(), &Sort { ascending: false, ..random(5) });
|
||||
let mut asc = c.query(&Filter::default(), &Sort { ascending: true, ..random(5) });
|
||||
asc.reverse();
|
||||
assert_eq!(desc, asc);
|
||||
// saved sorts from before the seed existed still load
|
||||
let old: Sort = serde_json::from_str(r#"{"key":"fileName","ascending":true}"#).unwrap();
|
||||
assert_eq!(old.seed, 0);
|
||||
assert_eq!(serde_json::from_str::<Sort>(r#"{"key":"random","seed":9}"#).unwrap(), Sort { key: SortKey::Random, seed: 9, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_has_no_date_headers() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 5);
|
||||
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
|
||||
}
|
||||
@@ -24,6 +24,12 @@ encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
```
|
||||
|
||||
Thumbnail fallback decoding limits the source to 64 million pixels by default.
|
||||
Non-JPEG decoders allocate at source resolution before resizing, even for a tiny
|
||||
thumbnail. Use `decode_thumbnail_with` and `ThumbnailOptions::max_pixels` to set
|
||||
a different source budget. Oversized sources return `Error::TooLarge`; embedded
|
||||
JPEG previews can still be used without decoding the full source image.
|
||||
|
||||
`Format::RawTiffLike` (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, …) and `Format::RawOther` (CR3, RAF,
|
||||
CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `decode` returns
|
||||
`Error::Unsupported` for them.
|
||||
|
||||
@@ -217,11 +217,19 @@ pub struct ThumbnailOptions {
|
||||
/// smaller size). Defaults to `max_edge`, i.e. only previews that need no upscaling. Lower it
|
||||
/// (e.g. 160 for typical EXIF thumbnails) for an instant placeholder.
|
||||
pub min_embedded_edge: u32,
|
||||
/// Maximum source pixels for fallback decoding (default 64 MP). Non-JPEG formats
|
||||
/// decode at source resolution before resizing, so output size does not bound memory.
|
||||
#[serde(default = "thumbnail_max_pixels")]
|
||||
pub max_pixels: u64,
|
||||
}
|
||||
|
||||
fn thumbnail_max_pixels() -> u64 {
|
||||
64_000_000
|
||||
}
|
||||
|
||||
impl ThumbnailOptions {
|
||||
pub fn new(max_edge: u32) -> Self {
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge }
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge, max_pixels: thumbnail_max_pixels() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +248,7 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
let d = decode(bytes, DecodeOptions::fit(max_edge, max_edge))?;
|
||||
let d = decode(bytes, DecodeOptions { max_size: Some((max_edge, max_edge)), max_pixels: opts.max_pixels })?;
|
||||
Ok(Thumbnail {
|
||||
image: d.to_srgb8(),
|
||||
orientation: d.orientation,
|
||||
|
||||
@@ -163,6 +163,9 @@ fn tiff_is_raw(b: &[u8]) -> bool {
|
||||
}
|
||||
let Some(t) = Tiff::new(b) else { return false };
|
||||
let Some(ifd0) = t.first_ifd() else { return false };
|
||||
let sony = t.ifd(ifd0).is_some_and(|(entries, _)| {
|
||||
entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY")))
|
||||
});
|
||||
let mut stack = vec![ifd0];
|
||||
let mut seen = 0;
|
||||
while let Some(pos) = stack.pop() {
|
||||
@@ -171,6 +174,12 @@ fn tiff_is_raw(b: &[u8]) -> bool {
|
||||
break;
|
||||
}
|
||||
let Some((entries, _)) = t.ifd(pos) else { continue };
|
||||
// Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr.
|
||||
// Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs.
|
||||
let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e));
|
||||
if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) {
|
||||
return true;
|
||||
}
|
||||
for e in &entries {
|
||||
match e.tag {
|
||||
0xC612 => return true, // DNGVersion
|
||||
@@ -233,4 +242,38 @@ mod tests {
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
assert_eq!(sniff(&b), Some(Format::RawTiffLike));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() {
|
||||
fn fixture(compression: u32, pattern: bool) -> Vec<u8> {
|
||||
let mut b = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entry = |b: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| {
|
||||
b.extend_from_slice(&tag.to_le_bytes());
|
||||
b.extend_from_slice(&typ.to_le_bytes());
|
||||
b.extend_from_slice(&count.to_le_bytes());
|
||||
b.extend_from_slice(&value.to_le_bytes());
|
||||
};
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
entry(&mut b, 0x010f, 2, 5, 38);
|
||||
entry(&mut b, 0x014a, 4, 1, 44);
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b.extend_from_slice(b"SONY\0\0");
|
||||
b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes());
|
||||
entry(&mut b, 0x0103, 3, 1, compression);
|
||||
entry(&mut b, 0x0106, 3, 1, 6);
|
||||
entry(&mut b, 0x0115, 3, 1, 3);
|
||||
if pattern {
|
||||
entry(&mut b, 0x828e, 1, 4, u32::MAX);
|
||||
}
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
let mut raw = fixture(7, true);
|
||||
assert_eq!(sniff(&raw), Some(Format::RawTiffLike));
|
||||
assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff));
|
||||
assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff));
|
||||
assert_eq!(sniff(&raw[..38]), Some(Format::Tiff));
|
||||
raw[38..42].copy_from_slice(b"TEST");
|
||||
assert_eq!(sniff(&raw), Some(Format::Tiff));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
// RIFF chunk lengths are untrusted. Metadata cannot exceed the input;
|
||||
// otherwise a tiny truncated file can request gigabytes before read_exact fails.
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let alpha = d.has_alpha();
|
||||
|
||||
@@ -32,10 +32,46 @@ fn try_all(bytes: &[u8]) {
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = decode_thumbnail(bytes, 16);
|
||||
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_truncated_metadata_cannot_allocate_declared_chunk_size() {
|
||||
let mut bytes = seeds()[5].clone();
|
||||
let tag = bytes.windows(4).rposition(|w| w == b"XMP ").unwrap();
|
||||
bytes[tag + 4..tag + 8].copy_from_slice(&1_946_157_060u32.to_le_bytes());
|
||||
let decoded = decode(&bytes, small_opts()).unwrap();
|
||||
assert_eq!((decoded.image.width, decoded.image.height), (24, 16));
|
||||
assert!(decoded.xmp.is_none(), "invalid optional metadata is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_rejects_huge_gif_before_allocating_source_pixels() {
|
||||
// A logical screen header without a global palette; previously this reached a
|
||||
// multi-gigabyte output allocation before discovering that no frame follows.
|
||||
let bytes = [
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x8f, 0xa4, 0xb0, 0x3f, 0x57, 0x14, 0xe8, 0x8a, 0xf8, 0xe0, 0xc7, 0x41, 0x9d, 0x0c, 0x10, 0xde, 0x54,
|
||||
0x24, 0xcb, 0xed, 0xff, 0x4a, 0xe7, 0xa0, 0x82, 0x10, 0xa0, 0xbc, 0xac, 0xe5, 0x33, 0x3f, 0xc9, 0x8d, 0x69, 0xf4, 0x24, 0xce, 0x99, 0x4f,
|
||||
0x0d, 0x47, 0xa0, 0xb0, 0xf4, 0xa8, 0x59, 0xad, 0x29, 0xbd, 0x09, 0x48, 0x63, 0xea, 0x59, 0xb7, 0xb7, 0xf2, 0xa1, 0x90, 0xeb, 0x92, 0xc5,
|
||||
0x9c, 0xde, 0xcc, 0x96, 0x67, 0xc4, 0xe5, 0x8c, 0x03, 0x50, 0xb2, 0x30, 0xf8, 0xf4, 0x69, 0x1d, 0xeb, 0xb9, 0xbd, 0x4e, 0xfb, 0xbd, 0xb6,
|
||||
0x02,
|
||||
];
|
||||
let result = decode_thumbnail(&bytes, 16);
|
||||
assert!(matches!(result, Err(Error::TooLarge(42_127, 16_304))), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_respects_configured_source_pixel_limit() {
|
||||
let image = Rgba8::from_fn(3, 2, |_, _| [40, 80, 120, 255]);
|
||||
let bytes = encode_png(&EncodeImage::rgba8(&image), &EncodeMeta::default()).unwrap();
|
||||
let mut opts = ThumbnailOptions::new(1);
|
||||
opts.max_pixels = 5;
|
||||
assert!(matches!(decode_thumbnail_with(&bytes, &opts), Err(Error::TooLarge(3, 2))));
|
||||
opts.max_pixels = 6;
|
||||
assert_eq!(decode_thumbnail_with(&bytes, &opts).unwrap().image.width, 1);
|
||||
}
|
||||
|
||||
fn seeds() -> &'static Vec<Vec<u8>> {
|
||||
static S: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
|
||||
S.get_or_init(|| {
|
||||
|
||||
@@ -461,7 +461,7 @@ fn thumbnail_accepts_smaller_preview_when_asked() {
|
||||
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::Scaled);
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { max_edge: 256, min_embedded_edge: 160 }).unwrap();
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { min_embedded_edge: 160, ..ThumbnailOptions::new(256) }).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||||
assert_eq!((t.image.width, t.image.height), (160, 120));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
pub mod controls;
|
||||
pub mod presets;
|
||||
pub mod segmask;
|
||||
pub mod settings;
|
||||
|
||||
pub use controls::{CONTROLS, ControlSpec, Section, Track};
|
||||
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
|
||||
pub use segmask::SegMask;
|
||||
pub use settings::*;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Model segmentations stored with a mask ([`SegMask`]), so rendering never needs the model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Logits are stored in steps of 1/`SCALE`, clamped to ±127/`SCALE` (≈ ±15.9: a sigmoid of
|
||||
/// 1 − 1.2e-7, i.e. fully in or out).
|
||||
const SCALE: f32 = 8.0;
|
||||
/// Largest grid a stored segmentation may claim (hostile input caps the allocation).
|
||||
pub const MAX_SIDE: usize = 1024;
|
||||
|
||||
/// A segmentation computed by a model (SAM 3 in `lightcraft-segment`): `side × side` logits
|
||||
/// over the uncropped, oriented image stretched to a square (row `y`, column `x` covers
|
||||
/// normalized `((x + 0.5) / side, (y + 0.5) / side)`), quantized to `i8`, deflated and base64
|
||||
/// encoded. Coverage at a point is the sigmoid of the bilinearly sampled logit, so edges stay
|
||||
/// smooth at any output size.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SegMask {
|
||||
pub side: u32,
|
||||
pub data: String,
|
||||
/// The part of the image the grid covers, normalized `[x0, y0, x1, y1]` (`None` = all of
|
||||
/// it). A zoomed-in pass over one object covers just its surroundings, at a higher
|
||||
/// resolution than a pass over the whole photo.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rect: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl SegMask {
|
||||
/// From `side × side` logits over the whole image.
|
||||
pub fn from_logits(side: usize, logits: &[f32]) -> SegMask {
|
||||
let q: Vec<u8> = logits.iter().take(side * side).map(|l| ((l * SCALE).round().clamp(-127.0, 127.0) as i8) as u8).collect();
|
||||
let z = miniz_oxide::deflate::compress_to_vec(&q, 8);
|
||||
SegMask { side: side as u32, data: base64_encode(&z), rect: None }
|
||||
}
|
||||
|
||||
/// From `side × side` logits over the part `rect` of the image.
|
||||
pub fn from_logits_in(side: usize, logits: &[f32], rect: [f64; 4]) -> SegMask {
|
||||
SegMask { rect: Some(rect), ..SegMask::from_logits(side, logits) }
|
||||
}
|
||||
|
||||
/// The covered part, normalized (the whole image without a `rect`); `None` when damaged.
|
||||
pub fn bounds(&self) -> Option<[f64; 4]> {
|
||||
let r = self.rect.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
|
||||
}
|
||||
|
||||
/// The logits, or `None` when the data is damaged or claims an absurd size.
|
||||
pub fn logits(&self) -> Option<Vec<f32>> {
|
||||
let side = self.side as usize;
|
||||
if side == 0 || side > MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
let z = base64_decode(&self.data)?;
|
||||
let q = miniz_oxide::inflate::decompress_to_vec_with_limit(&z, side * side).ok()?;
|
||||
(q.len() == side * side).then(|| q.iter().map(|b| f32::from(*b as i8) / SCALE).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Most zoomed-in detail patches one AI mask component keeps (the app makes one; each costs a
|
||||
/// decode per render, so a hostile document can't make rendering explode).
|
||||
pub const MAX_DETAIL: usize = 4;
|
||||
|
||||
/// Deserialize a component's detail patches, keeping at most [`MAX_DETAIL`] (the rest are
|
||||
/// skipped without being decoded).
|
||||
pub(crate) fn de_detail<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<SegMask>, D::Error> {
|
||||
struct Capped;
|
||||
impl<'de> serde::de::Visitor<'de> for Capped {
|
||||
type Value = Vec<SegMask>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of segmentations")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<SegMask>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < MAX_DETAIL {
|
||||
match seq.next_element::<SegMask>()? {
|
||||
Some(m) => out.push(m),
|
||||
None => return Ok(out),
|
||||
}
|
||||
}
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Capped)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(char::from(B64[((n >> (18 - 6 * i)) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace()) {
|
||||
if c == b'=' {
|
||||
break;
|
||||
}
|
||||
let v = B64.iter().position(|b| *b == c)? as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_round_trips() {
|
||||
for n in 0..10 {
|
||||
let v: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&v)).unwrap(), v);
|
||||
}
|
||||
assert_eq!(base64_encode(b"Man"), "TWFu");
|
||||
assert_eq!(base64_encode(b"Ma"), "TWE=");
|
||||
assert!(base64_decode("TW!u").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logits_round_trip_quantized() {
|
||||
let side = 16;
|
||||
let l: Vec<f32> = (0..side * side).map(|i| (i as f32 - 128.0) / 4.0).collect();
|
||||
let m = SegMask::from_logits(side, &l);
|
||||
let back = m.logits().unwrap();
|
||||
for (a, b) in l.iter().zip(&back) {
|
||||
assert!((a.clamp(-127.0 / SCALE, 127.0 / SCALE) - b).abs() <= 0.5 / SCALE + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_document_keeps_at_most_max_detail_patches() {
|
||||
let patch = serde_json::to_value(SegMask::from_logits(4, &[1.0; 16])).unwrap();
|
||||
let shape = serde_json::json!({"kind": "object", "hint": [{"x": 0.5, "y": 0.5}], "detail": vec![patch; 10_000]});
|
||||
let s: crate::MaskShape = serde_json::from_value(shape).unwrap();
|
||||
let crate::MaskShape::Object { detail, .. } = s else { panic!("an Object") };
|
||||
assert_eq!(detail.len(), MAX_DETAIL);
|
||||
// and a whole develop document with them still loads
|
||||
let mut d = crate::DevelopSettings::default();
|
||||
d.masks.push(crate::Mask {
|
||||
id: 1,
|
||||
components: vec![crate::MaskComponent {
|
||||
name: None,
|
||||
op: crate::MaskOp::Add,
|
||||
invert: false,
|
||||
shape: crate::MaskShape::Prompt { text: "x".into(), seg: None, detail: vec![SegMask::from_logits(4, &[1.0; 16]); 50], edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let back: crate::DevelopSettings = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
|
||||
let crate::MaskShape::Prompt { detail, .. } = &back.masks[0].components[0].shape else { panic!("a Prompt") };
|
||||
assert_eq!(detail.len(), MAX_DETAIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_data_is_none() {
|
||||
let mut m = SegMask::from_logits(8, &[1.0; 64]);
|
||||
m.side = 9;
|
||||
assert!(m.logits().is_none(), "size mismatch");
|
||||
m.side = 100_000;
|
||||
assert!(m.logits().is_none(), "absurd size");
|
||||
assert!(SegMask { side: 8, data: "not base64 !".into(), rect: None }.logits().is_none());
|
||||
assert!(SegMask { rect: Some([0.5, 0.0, 0.2, 1.0]), ..SegMask::from_logits(2, &[0.0; 4]) }.bounds().is_none());
|
||||
}
|
||||
}
|
||||
@@ -772,8 +772,33 @@ pub enum MaskShape {
|
||||
Subject,
|
||||
Sky,
|
||||
Background,
|
||||
/// One object picked by clicks (SAM 3 point prompts): `hint` holds the clicks that include,
|
||||
/// `exclude` the ones that exclude; `seg` the segmentation computed from them.
|
||||
Object {
|
||||
hint: Vec<Point>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
exclude: Vec<Point>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seg: Option<crate::SegMask>,
|
||||
/// Zoomed-in passes over parts of the image (higher resolution than `seg`), used
|
||||
/// inside their rectangles.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
|
||||
detail: Vec<crate::SegMask>,
|
||||
/// Edge −100..100: below 0 harder (a steeper transition), above 0 softer (feathered).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
edge: f64,
|
||||
},
|
||||
/// Everything a description names ("sky", "the red car": SAM 3 concept prompts); `seg` is
|
||||
/// the segmentation computed for it.
|
||||
Prompt {
|
||||
text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seg: Option<crate::SegMask>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
|
||||
detail: Vec<crate::SegMask>,
|
||||
/// Edge −100..100, as for `Object`.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
edge: f64,
|
||||
},
|
||||
People {
|
||||
person: u32,
|
||||
|
||||
@@ -30,6 +30,11 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
|
||||
sam = ["dep:lightcraft-segment"]
|
||||
|
||||
[dev-dependencies]
|
||||
libc = { workspace = true }
|
||||
@@ -0,0 +1,86 @@
|
||||
//! 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::{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");
|
||||
let mut embedded = 0;
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => {
|
||||
embedded = entries.lines().count();
|
||||
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");
|
||||
// Without them the app still runs and every language still selectable, so Chinese and Japanese
|
||||
// just paint boxes — the one failure a user cannot diagnose from what they see.
|
||||
if embedded == 0 {
|
||||
println!(
|
||||
"cargo::warning=no craft-fonts embedded: Chinese and Japanese text will render as empty boxes. \
|
||||
Clone https://github.com/storytold/craft-fonts next to this repo and rebuild with \
|
||||
CRAFT_FONTS_DIR=../craft-fonts"
|
||||
);
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a relative `CRAFT_FONTS_DIR` against both roots a caller might mean: a build script's
|
||||
/// cwd is the *package* root (`crates/engine`), while the `../craft-fonts` the README and
|
||||
/// craft-fonts' docs use is written relative to the *workspace* root and would otherwise resolve
|
||||
/// to `crates/craft-fonts`.
|
||||
fn resolve(dir: PathBuf) -> PathBuf {
|
||||
if dir.is_absolute() {
|
||||
return dir;
|
||||
}
|
||||
let package = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
let workspace = package.parent().and_then(Path::parent).unwrap_or(package.as_path());
|
||||
let candidates = [workspace.join(&dir), package.join(&dir)];
|
||||
candidates.iter().find(|candidate| candidate.is_dir()).cloned().unwrap_or_else(|| candidates[0].clone())
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &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)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Is a file there? — answered without blocking the caller.
|
||||
//!
|
||||
//! On a sleeping NAS, a dropped share or a USB drive spinning up, one `stat` can take as long as
|
||||
//! the OS or SMB timeout. The UI asks about files every frame (grid cells, the Info panel, the
|
||||
//! Missing Photos view), so in the app the answers come from a cache that a worker thread fills
|
||||
//! ([`Availability::run_in_background`]): an unknown file is reported as `None` ("assume it is
|
||||
//! there"; the render worker finds out if it isn't), known answers are rechecked after
|
||||
//! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a
|
||||
//! fresh check, as before.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Checks whether a path exists (injectable: tests use a slow one).
|
||||
pub type ExistsProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
|
||||
/// Called (from the worker) when an answer changed, e.g. to repaint the UI.
|
||||
pub type Waker = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// How long a cached answer is used before it is checked again (it is still returned meanwhile).
|
||||
pub const RECHECK: Duration = Duration::from_secs(15);
|
||||
|
||||
fn fs_exists(path: &str) -> bool {
|
||||
// the browser build has no file system: its originals are always "there"
|
||||
cfg!(target_arch = "wasm32") || std::path::Path::new(path).exists()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// path → (exists, when checked)
|
||||
known: HashMap<String, (bool, std::time::Instant)>,
|
||||
queue: VecDeque<String>,
|
||||
queued: HashSet<String>,
|
||||
worker: bool,
|
||||
/// Bumped whenever an answer changes (callers recompute what depends on it).
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// File availability, synchronous or cached + background-checked. Cloning shares the cache.
|
||||
#[derive(Clone)]
|
||||
pub struct Availability {
|
||||
probe: ExistsProbe,
|
||||
background: Option<Waker>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl Default for Availability {
|
||||
fn default() -> Self {
|
||||
Self { probe: Arc::new(fs_exists), background: None, state: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Availability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Availability").field("background", &self.background.is_some()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Availability {
|
||||
/// Replace the existence check (tests: a slow or counting probe). Forgets cached answers.
|
||||
pub fn set_probe(&mut self, probe: ExistsProbe) {
|
||||
self.probe = probe;
|
||||
let mut st = lock(&self.state);
|
||||
st.known.clear();
|
||||
st.generation += 1;
|
||||
}
|
||||
|
||||
/// Answer from the cache and check on a worker thread from now on; `wake` runs when an
|
||||
/// answer changed. No effect in the browser build (no threads, no file system).
|
||||
pub fn run_in_background(&mut self, wake: Waker) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
self.background = Some(wake);
|
||||
}
|
||||
|
||||
/// The existence check itself (blocking: for worker threads, e.g. the Rename preview's).
|
||||
pub fn probe(&self) -> ExistsProbe {
|
||||
self.probe.clone()
|
||||
}
|
||||
|
||||
pub fn is_background(&self) -> bool {
|
||||
self.background.is_some()
|
||||
}
|
||||
|
||||
/// Does `path` exist? Synchronous mode: checked now. Background mode: the cached answer
|
||||
/// (`None` while unknown); unknown or stale answers are queued for the worker.
|
||||
pub fn exists(&self, path: &str) -> Option<bool> {
|
||||
let Some(wake) = &self.background else {
|
||||
return Some((self.probe)(path));
|
||||
};
|
||||
let mut st = lock(&self.state);
|
||||
let now = std::time::Instant::now();
|
||||
let cached = st.known.get(path).copied();
|
||||
if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) {
|
||||
st.queued.insert(path.to_string());
|
||||
st.queue.push_back(path.to_string());
|
||||
if !st.worker {
|
||||
st.worker = true;
|
||||
drop(st);
|
||||
self.spawn_worker(wake.clone());
|
||||
return cached.map(|c| c.0);
|
||||
}
|
||||
}
|
||||
cached.map(|c| c.0)
|
||||
}
|
||||
|
||||
/// Known to be missing (unknown counts as present).
|
||||
pub fn is_offline(&self, path: &str) -> bool {
|
||||
self.exists(path) == Some(false)
|
||||
}
|
||||
|
||||
/// Changes whenever a cached answer changes.
|
||||
pub fn generation(&self) -> u64 {
|
||||
lock(&self.state).generation
|
||||
}
|
||||
|
||||
/// Paths waiting for the worker.
|
||||
pub fn pending(&self) -> usize {
|
||||
lock(&self.state).queue.len()
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, wake: Waker) {
|
||||
let (state, probe) = (self.state.clone(), self.probe.clone());
|
||||
let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || {
|
||||
loop {
|
||||
let path = {
|
||||
let mut st = lock(&state);
|
||||
let Some(p) = st.queue.pop_front() else {
|
||||
st.worker = false;
|
||||
return;
|
||||
};
|
||||
p
|
||||
};
|
||||
let ok = probe(&path);
|
||||
let changed = {
|
||||
let mut st = lock(&state);
|
||||
st.queued.remove(&path);
|
||||
let prev = st.known.insert(path, (ok, std::time::Instant::now()));
|
||||
let changed = prev.map(|p| p.0) != Some(ok);
|
||||
if changed {
|
||||
st.generation += 1;
|
||||
}
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
if started.is_err() {
|
||||
// try again on the next question
|
||||
lock(&self.state).worker = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn synchronous_by_default() {
|
||||
let mut a = Availability::default();
|
||||
let n = Arc::new(AtomicUsize::new(0));
|
||||
let c = n.clone();
|
||||
a.set_probe(Arc::new(move |p| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
p == "here"
|
||||
}));
|
||||
assert_eq!(a.exists("here"), Some(true));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert_eq!(n.load(Ordering::SeqCst), 3, "every question is a fresh check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_answers_from_the_cache_without_blocking() {
|
||||
let mut a = Availability::default();
|
||||
let caller = std::thread::current().id();
|
||||
let on_caller = Arc::new(AtomicUsize::new(0));
|
||||
let c = on_caller.clone();
|
||||
a.set_probe(Arc::new(move |p| {
|
||||
if std::thread::current().id() == caller {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200)); // a sleeping NAS
|
||||
p == "here"
|
||||
}));
|
||||
let woke = Arc::new(AtomicUsize::new(0));
|
||||
let w = woke.clone();
|
||||
a.run_in_background(Arc::new(move || {
|
||||
w.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let t0 = std::time::Instant::now();
|
||||
assert_eq!(a.exists("here"), None, "unknown at first");
|
||||
assert_eq!(a.exists("gone"), None);
|
||||
assert!(t0.elapsed() < Duration::from_millis(150), "the caller never waits for the probe");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
while (a.exists("here").is_none() || a.exists("gone").is_none()) && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(a.exists("here"), Some(true));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
|
||||
assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread");
|
||||
assert!(woke.load(Ordering::SeqCst) >= 2);
|
||||
assert!(a.generation() >= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
|
||||
//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! never output pixels or a replacement for RAW editing.
|
||||
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
|
||||
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
|
||||
//! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels.
|
||||
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford, luminance_2020};
|
||||
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
|
||||
use lightcraft_raster::{
|
||||
Rgb32f,
|
||||
resample::{Filter, fit},
|
||||
};
|
||||
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
pub matrix: Mat3,
|
||||
pub tone: CameraTone,
|
||||
/// Hue/saturation correction after `matrix`, in linear ProPhoto RGB (DNG `ProfileHueSatMap`).
|
||||
pub hue_sat: Option<HsvTable>,
|
||||
}
|
||||
|
||||
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
|
||||
/// below were set at this size).
|
||||
const PROXY: usize = 96;
|
||||
/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples.
|
||||
pub(crate) const PROFILE_PROXY: usize = 192;
|
||||
|
||||
/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix:
|
||||
/// their starting look is fitted to the file's own JPEG, and white balance is relative to the
|
||||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||||
/// same formats by file extension.
|
||||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// only the tone and chroma curves are fitted per photo (DRO and picture styles vary).
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let look = fit_pairs_with(&sensor, &reference, colour)?;
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
|
||||
raw.format,
|
||||
look.matrix.0,
|
||||
look.tone,
|
||||
look.hue_sat.is_some(),
|
||||
profile.is_some()
|
||||
);
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02;
|
||||
if !matches(crop.width, crop.height, reference.width, reference.height) {
|
||||
// Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio
|
||||
if !matches(a.width, a.height, reference.width, reference.height) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64);
|
||||
let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize);
|
||||
let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize);
|
||||
if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height {
|
||||
return None;
|
||||
}
|
||||
reference = reference.into_crop(x, y, w, h);
|
||||
}
|
||||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||||
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
|
||||
Some((sensor, reference))
|
||||
}
|
||||
|
||||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||||
/// colour matrices, other files or unusable previews.
|
||||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
return None;
|
||||
}
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference)?;
|
||||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||||
}
|
||||
|
||||
/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table)
|
||||
/// fitted to pairs pooled from many photos.
|
||||
pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option<HsvTable>)> {
|
||||
let matrix = fit_matrix(pairs)?;
|
||||
Some((matrix, fit_hue_sat(pairs, &matrix)))
|
||||
}
|
||||
|
||||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||||
Some(match raw.develop_binned(k, 0.99).ok()? {
|
||||
Some(sensor) => sensor,
|
||||
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
|
||||
None => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A fit must cut the held-out squared error to below this share of the fallback's.
|
||||
const MIN_IMPROVEMENT: f64 = 0.7;
|
||||
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
|
||||
/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed
|
||||
/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve
|
||||
/// cannot follow): visibly better renders that 0.055 rejected.
|
||||
const MAX_HOLDOUT_RMS: f64 = 0.10;
|
||||
|
||||
fn luma(p: [f64; 3]) -> f64 {
|
||||
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
|
||||
}
|
||||
|
||||
/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`).
|
||||
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
|
||||
let scene = scene.map(|v| v.max(0.0));
|
||||
let y = luma(scene);
|
||||
if y <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let o = f64::from(tone.apply(y as f32));
|
||||
let k = f64::from(tone.chroma_scale(o as f32));
|
||||
scene.map(|v| o + (v * o / y - o) * k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
fit_pairs_with(sensor, reference, None)
|
||||
}
|
||||
|
||||
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
|
||||
/// (for the chroma curve); `None` when too few, or the photo has too little colour.
|
||||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
|
||||
let mut bright = Vec::new();
|
||||
let mut colour = 0;
|
||||
for (input, output) in sensor.data.iter().zip(&reference.data) {
|
||||
let y = luminance_2020(*output);
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||||
continue;
|
||||
}
|
||||
if (0.015..=1.0).contains(&y) {
|
||||
bright.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let max = output.iter().copied().fold(0.0, f32::max);
|
||||
colour += usize::from(max - min > 0.05);
|
||||
pairs.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
|
||||
}
|
||||
|
||||
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
|
||||
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||||
let mut gram = [[0.0; 3]; 3];
|
||||
let mut cross = [[0.0; 3]; 3];
|
||||
for (i, (x, y)) in pairs.iter().enumerate() {
|
||||
if i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let (lx, ly) = (luma(*x), luma(*y));
|
||||
if lx <= 0.0 || ly <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for row in 0..3 {
|
||||
for col in 0..3 {
|
||||
// Normalising by luminance prevents a camera S-curve from corrupting colour.
|
||||
gram[row][col] += x[row] * x[col] / (lx * lx);
|
||||
cross[row][col] += y[row] * x[col] / (ly * lx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let trace: f64 = (0..3).map(|i| gram[i][i]).sum();
|
||||
let inverse = Mat3(gram).inverse()?;
|
||||
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
|
||||
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
|
||||
return None;
|
||||
}
|
||||
let regularization = trace * 1e-4;
|
||||
for i in 0..3 {
|
||||
gram[i][i] += regularization;
|
||||
cross[i][i] += regularization;
|
||||
}
|
||||
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
|
||||
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
|
||||
}
|
||||
|
||||
/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
|
||||
/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
|
||||
/// and chroma curve fitted to this photo.
|
||||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
let (pairs, bright) = collect_pairs(sensor, reference)?;
|
||||
let candidates = match colour {
|
||||
Some(given) => vec![given],
|
||||
None => {
|
||||
let matrix = fit_matrix(&pairs)?;
|
||||
vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)]
|
||||
}
|
||||
};
|
||||
// Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by
|
||||
// error in gamma-encoded display values (closer to what is seen: in linear values a slightly
|
||||
// missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear.
|
||||
let mut best: Option<(f64, f64, usize, CameraLook)> = None;
|
||||
for (matrix, hue_sat) in candidates {
|
||||
let correction = hue_sat.as_ref().and_then(HueSat::new);
|
||||
let colour = |x: [f64; 3]| {
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let Some(curve) = fit_tone(tone_pairs) else { continue };
|
||||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
}
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
|
||||
for (x, target) in pairs.iter().step_by(3) {
|
||||
let corrected = displayed(colour(*x), &tone);
|
||||
for c in 0..3 {
|
||||
linear += (corrected[c] - target[c]).powi(2);
|
||||
perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2);
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) {
|
||||
best = Some((perceptual, linear, samples, look));
|
||||
}
|
||||
}
|
||||
let (_, after, samples, look) = best?;
|
||||
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
|
||||
let before: f64 = pairs
|
||||
.iter()
|
||||
.step_by(3)
|
||||
.map(|(x, target)| {
|
||||
let original = displayed(*x, &original_tone);
|
||||
(0..3).map(|c| (original[c] - target[c]).powi(2)).sum::<f64>()
|
||||
})
|
||||
.sum();
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)",
|
||||
(before / samples as f64).sqrt(),
|
||||
(after / samples as f64).sqrt()
|
||||
);
|
||||
}
|
||||
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||||
return None;
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
|
||||
fn to_prophoto() -> Mat3 {
|
||||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||||
}
|
||||
|
||||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||||
pub(crate) struct HueSat<'a> {
|
||||
table: &'a HsvTable,
|
||||
to: [[f32; 3]; 3],
|
||||
from: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
impl<'a> HueSat<'a> {
|
||||
pub fn new(table: &'a HsvTable) -> Option<HueSat<'a>> {
|
||||
let to = to_prophoto();
|
||||
Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||||
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
|
||||
}
|
||||
}
|
||||
|
||||
/// HSV hue (degrees) and saturation; `None` for black or non-finite colours.
|
||||
fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> {
|
||||
let max = p[0].max(p[1]).max(p[2]);
|
||||
let min = p[0].min(p[1]).min(p[2]);
|
||||
if !(max.is_finite() && min.is_finite()) || max <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let d = max - min;
|
||||
if d <= 0.0 {
|
||||
return Some((0.0, 0.0));
|
||||
}
|
||||
let h = if max == p[0] {
|
||||
((p[1] - p[2]) / d).rem_euclid(6.0)
|
||||
} else if max == p[1] {
|
||||
(p[2] - p[0]) / d + 2.0
|
||||
} else {
|
||||
(p[0] - p[1]) / d + 4.0
|
||||
};
|
||||
Some((h * 60.0, d / max))
|
||||
}
|
||||
|
||||
/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns
|
||||
/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately.
|
||||
const TABLE_HUES: usize = 72;
|
||||
const TABLE_SATS: usize = 5;
|
||||
/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward
|
||||
/// orange but bright yellow-greens toward green, which one shift per hue averages away.
|
||||
const TABLE_VALS: usize = 5;
|
||||
/// Kernel widths around each table node (hue in degrees, saturation, encoded value).
|
||||
const KERNEL_HUE: f64 = 2.5;
|
||||
const KERNEL_SAT: f64 = 0.15;
|
||||
const KERNEL_VAL: f64 = 0.12;
|
||||
/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity.
|
||||
const SHRINK_WEIGHT: f64 = 5.0;
|
||||
|
||||
/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue,
|
||||
/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples.
|
||||
/// Saturation 0 stays identity, so neutrals are never tinted.
|
||||
fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option<HsvTable> {
|
||||
let to = to_prophoto();
|
||||
let samples: Vec<(f64, f64, f64, f64, f64)> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 3 != 0)
|
||||
.filter_map(|(_, (x, y))| {
|
||||
let p = to.apply(matrix.apply(*x));
|
||||
let (hp, sp) = hue_saturation(p)?;
|
||||
let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32));
|
||||
let (ht, st) = hue_saturation(to.apply(*y))?;
|
||||
// hue is meaningless near neutral
|
||||
if sp < 0.08 || st < 0.02 {
|
||||
return None;
|
||||
}
|
||||
let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0);
|
||||
let log_scale = (st / sp).ln().clamp(-0.7, 0.7);
|
||||
Some((hp, sp.min(1.0), value, shift, log_scale))
|
||||
})
|
||||
.collect();
|
||||
if samples.len() < 64 {
|
||||
return None;
|
||||
}
|
||||
// Samples by table hue step: a node only looks at hues within its kernel's reach.
|
||||
let step = 360.0 / TABLE_HUES as f64;
|
||||
let mut by_hue: Vec<Vec<(f64, f64, f64, f64, f64)>> = vec![Vec::new(); TABLE_HUES];
|
||||
for sample in samples {
|
||||
if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) {
|
||||
bin.push(sample);
|
||||
}
|
||||
}
|
||||
let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1;
|
||||
use rayon::prelude::*;
|
||||
let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS)
|
||||
.into_par_iter()
|
||||
.map(|index| {
|
||||
let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS);
|
||||
if s == 0 {
|
||||
return [0.0, 1.0, 1.0];
|
||||
}
|
||||
let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64);
|
||||
let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0);
|
||||
for offset in 0..=2 * reach {
|
||||
let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue };
|
||||
for &(hp, sp, vp, dh, ls) in bin {
|
||||
let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dhue.abs() > 4.0 * KERNEL_HUE {
|
||||
continue;
|
||||
}
|
||||
let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2);
|
||||
let k = (-0.5 * d2).exp() * sp;
|
||||
weight += k;
|
||||
shift += k * dh;
|
||||
log_scale += k * ls;
|
||||
}
|
||||
}
|
||||
if weight <= 0.0 {
|
||||
return [0.0, 1.0, 1.0];
|
||||
}
|
||||
let shrink = 1.0 / (weight + SHRINK_WEIGHT);
|
||||
[(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0]
|
||||
})
|
||||
.collect();
|
||||
data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable {
|
||||
hue_divisions: TABLE_HUES,
|
||||
sat_divisions: TABLE_SATS,
|
||||
val_divisions: TABLE_VALS,
|
||||
data,
|
||||
srgb_value: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node
|
||||
/// keeps half of its estimate.
|
||||
const CHROMA_KERNEL: f64 = 0.08;
|
||||
const CHROMA_SHRINK: f64 = 2.0;
|
||||
|
||||
/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds
|
||||
/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as
|
||||
/// the distance from neutral of luminance-normalised RGB.
|
||||
fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<CameraTone> {
|
||||
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
|
||||
let scene = |x: &[f64; 3]| {
|
||||
let p = look.matrix.apply(*x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let predict = |x: &[f64; 3]| displayed(scene(x), &tone);
|
||||
let chroma = |p: [f64; 3]| {
|
||||
let y = luma(p);
|
||||
(y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::<f64>().sqrt())
|
||||
};
|
||||
let samples: Vec<(f64, f64, f64)> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 3 != 0)
|
||||
.filter_map(|(_, (x, y))| {
|
||||
let p = predict(x);
|
||||
let (cp, cy) = (chroma(p)?, chroma(*y)?);
|
||||
(cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln())))
|
||||
})
|
||||
.collect();
|
||||
if samples.len() < 64 {
|
||||
return None;
|
||||
}
|
||||
let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N];
|
||||
for (j, node) in curve.iter_mut().enumerate() {
|
||||
let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64;
|
||||
let (mut weight, mut sum) = (0.0, 0.0);
|
||||
for &(o, cp, log_ratio) in &samples {
|
||||
let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp;
|
||||
weight += k;
|
||||
sum += k * log_ratio;
|
||||
}
|
||||
*node = (sum / (weight + CHROMA_SHRINK)).exp() as f32;
|
||||
}
|
||||
let fitted = look.tone.with_chroma(curve)?;
|
||||
let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0);
|
||||
let error = |map: &ToneMap| -> f64 {
|
||||
pairs
|
||||
.iter()
|
||||
.step_by(3)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(scene(x), map);
|
||||
(0..3).map(|c| (p[c] - y[c]).powi(2)).sum::<f64>()
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
let (before, after) = (error(&tone), error(&with));
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}");
|
||||
}
|
||||
(after.is_finite() && after < before).then_some(fitted)
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> Option<f64> {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.get(values.len() / 2).copied()
|
||||
}
|
||||
|
||||
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut knots = [[0.0; 2]; 32];
|
||||
for (i, knot) in knots.iter_mut().enumerate() {
|
||||
let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?;
|
||||
let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect();
|
||||
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
|
||||
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
|
||||
}
|
||||
if knots[31][0] < knots[0][0] * 1.5 {
|
||||
return None;
|
||||
}
|
||||
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
|
||||
let mut blocks: Vec<(f32, usize)> = Vec::new();
|
||||
for knot in knots {
|
||||
blocks.push((knot[1], 1));
|
||||
while blocks.len() >= 2 {
|
||||
let (a, an) = *blocks.get(blocks.len() - 2)?;
|
||||
let (b, bn) = *blocks.last()?;
|
||||
if a <= b {
|
||||
break;
|
||||
}
|
||||
blocks.truncate(blocks.len() - 2);
|
||||
blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn));
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
for (y, n) in blocks {
|
||||
for knot in knots.get_mut(i..i + n)? {
|
||||
knot[1] = y;
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linear_arw_gets_a_sensor_proxy_without_demosaicing() {
|
||||
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect};
|
||||
let mut raw = RawImage {
|
||||
format: RawFormat::Arw,
|
||||
width: 32,
|
||||
height: 32,
|
||||
cpp: 3,
|
||||
data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)),
|
||||
cfa: None,
|
||||
bits: 16,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
white: vec![1.0],
|
||||
active_area: Rect::new(0, 0, 32, 32),
|
||||
crop: Rect::new(0, 0, 32, 32),
|
||||
orientation: Orientation::from_exif(1),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: true,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata::default(),
|
||||
};
|
||||
let proxy = sensor_proxy(&raw, 2, 384).unwrap();
|
||||
assert_eq!((proxy.width, proxy.height), (32, 32));
|
||||
assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]);
|
||||
raw.data = RawData::F32(Vec::new());
|
||||
assert!(sensor_proxy(&raw, 2, 384).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||||
}
|
||||
let original = sensor.clone();
|
||||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||||
assert_eq!(sensor.data, original.data);
|
||||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let error: f64 = sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ sensor.data.len() as f64;
|
||||
assert!(error.sqrt() < 0.025, "{error}");
|
||||
// The colour transform is homogeneous; tone mapping happens only after exposure.
|
||||
let p = fit.matrix.apply([2.0, 2.0, 2.0]);
|
||||
assert!(luma(p) > 1.0);
|
||||
assert!(tone.apply(0.2) < tone.apply(0.4));
|
||||
}
|
||||
#[test]
|
||||
fn accepts_a_much_better_fit_despite_local_camera_processing() {
|
||||
// The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12
|
||||
// per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback.
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let noise = (seed % 2001) as f32 / 1000.0 - 1.0;
|
||||
(v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97)
|
||||
});
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_some());
|
||||
}
|
||||
|
||||
/// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime
|
||||
/// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most
|
||||
/// of the gap, keep neutrals neutral and keep luminance.
|
||||
#[test]
|
||||
fn hue_table_follows_a_hue_dependent_camera_rendering() {
|
||||
let to = to_prophoto();
|
||||
let from = to.inverse().unwrap();
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
let mut lime = Vec::new();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 29) as f32 * 0.02;
|
||||
// mostly greys and mild colours, plus a patch of saturated yellow-green
|
||||
*src = if i % 7 == 0 {
|
||||
[ev * 0.75, ev, ev * 0.2]
|
||||
} else {
|
||||
[ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)]
|
||||
};
|
||||
let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap();
|
||||
// the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation
|
||||
let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0);
|
||||
let mut p = to.apply(src.map(f64::from)).map(|v| v as f32);
|
||||
let table = HsvTable {
|
||||
hue_divisions: 1,
|
||||
sat_divisions: 2,
|
||||
val_divisions: 1,
|
||||
data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]],
|
||||
srgb_value: false,
|
||||
};
|
||||
p = table.apply(p);
|
||||
let p = from.apply(p.map(f64::from)).map(|v| v as f32);
|
||||
let scale = luminance_2020(*src) / luminance_2020(p);
|
||||
let p = p.map(|v| v * scale);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||||
if i % 7 == 0 {
|
||||
lime.push(i);
|
||||
}
|
||||
}
|
||||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||||
let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted");
|
||||
let hue_sat = HueSat::new(table).unwrap();
|
||||
let hue_error = |with_table: bool| {
|
||||
lime.iter()
|
||||
.map(|&i| {
|
||||
let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32);
|
||||
let p = if with_table { hue_sat.apply(p) } else { p };
|
||||
let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap();
|
||||
let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap();
|
||||
((h - t + 180.0).rem_euclid(360.0) - 180.0).abs()
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ lime.len() as f64
|
||||
};
|
||||
let (before, after) = (hue_error(false), hue_error(true));
|
||||
assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°");
|
||||
// neutrals pass through and luminance is kept
|
||||
let grey = [0.3, 0.3, 0.3];
|
||||
assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey));
|
||||
let green = [0.2, 0.4, 0.05];
|
||||
assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve),
|
||||
/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright
|
||||
/// warm-tinted rock renders white instead of cream.
|
||||
#[test]
|
||||
fn chroma_curve_follows_highlight_bleaching() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let camera_chroma = |o: f32| 1.3 - 1.1 * o;
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.02 + (i % 37) as f32 * 0.03;
|
||||
*src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
let o = 1.0 - (-2.5 * y).exp();
|
||||
let k = camera_chroma(o);
|
||||
*dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999));
|
||||
}
|
||||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||||
// relative to the matrix, which already carries the average colourfulness
|
||||
let chroma = fit.tone.chroma();
|
||||
assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}");
|
||||
// and the rendered highlights land on the camera's, far closer than without the curve
|
||||
let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0);
|
||||
let highlight_error = |map: &ToneMap| -> f64 {
|
||||
sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.filter(|(_, y)| luminance_2020(**y) > 0.7)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(fit.matrix.apply(x.map(f64::from)), map);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>()
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
let (a, b) = (highlight_error(&without), highlight_error(&with));
|
||||
assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}");
|
||||
}
|
||||
|
||||
/// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that
|
||||
/// colour model (a camera profile) keeps it, fitting only its own tone.
|
||||
#[test]
|
||||
fn profile_colour_is_pooled_and_then_used_as_given() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let photo = |seed: usize, strength: f32| {
|
||||
let mut sensor = Rgb32f::new(64, 48);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let i = i + seed * 7;
|
||||
let ev = 0.04 + (i % 23) as f32 * 0.02;
|
||||
*src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
// each photo has its own tone (DRO, picture style)
|
||||
*dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y);
|
||||
}
|
||||
(sensor, reference)
|
||||
};
|
||||
let mut pool = Vec::new();
|
||||
for seed in 0..4 {
|
||||
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
|
||||
pool.extend(collect_pairs(&sensor, &reference).unwrap().0);
|
||||
}
|
||||
let (matrix, _) = fit_profile(&pool).unwrap();
|
||||
// the camera's colours (luminance-normalised: the tone curve sets brightness)
|
||||
for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] {
|
||||
let (a, b) = (matrix.apply(x), known.apply(x));
|
||||
let (la, lb) = (luma(a), luma(b));
|
||||
assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}");
|
||||
}
|
||||
let (sensor, reference) = photo(9, 3.5);
|
||||
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
|
||||
assert_eq!(look.matrix, matrix);
|
||||
assert!(look.hue_sat.is_none());
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let error = sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ sensor.data.len() as f64;
|
||||
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_sat_passes_black_and_non_finite_pixels_through() {
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||||
let hue_sat = HueSat::new(&table).unwrap();
|
||||
assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]);
|
||||
let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]);
|
||||
assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1);
|
||||
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
|
||||
}
|
||||
|
||||
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
|
||||
/// balance is relative to the as-shot look.
|
||||
#[test]
|
||||
fn corpus_nef_gets_a_camera_look() {
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/nef-nikon-d7500-lossless14.nef");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||||
}
|
||||
|
||||
/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3
|
||||
/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it.
|
||||
#[test]
|
||||
fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() {
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio");
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
*p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1];
|
||||
}
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
reference.data.fill([f32::NAN; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
*src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01];
|
||||
*dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
sensor.data.fill([0.1, 0.15, 0.12]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
reference.data.truncate(8);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Camera colour profiles of our own, pooled from many photos of one camera model.
|
||||
//!
|
||||
//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
|
||||
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
|
||||
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
|
||||
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
|
||||
//! table; photos of that model then only fit their tone and chroma curves.
|
||||
//!
|
||||
//! Profiles are JSON files (`<model>.json`) in [`dir`]: `LIGHTCRAFT_CAMERA_PROFILES`, else
|
||||
//! `<config>/camera-profiles`; a local profile replaces the one built in ([`BUNDLED`], from
|
||||
//! `assets/camera-profiles/`). They are read once per process; a damaged or hostile file is
|
||||
//! ignored with a warning. They hold aggregate colour statistics only, never image content.
|
||||
use lightcraft_color::Mat3;
|
||||
use lightcraft_raw::profile::HsvTable;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
/// Format version of the profile files.
|
||||
const VERSION: u32 = 1;
|
||||
/// Largest profile file read (a 5 × 72 × 5 table is ~60 KB of JSON).
|
||||
const MAX_FILE: u64 = 4 << 20;
|
||||
|
||||
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
|
||||
/// `(model, JSON)`.
|
||||
pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CameraProfile {
|
||||
pub version: u32,
|
||||
/// The camera model as the files report it (Exif `Model`), e.g. `ILCE-7M4`.
|
||||
pub model: String,
|
||||
/// Photos and colour pairs the profile was fitted on.
|
||||
pub files: usize,
|
||||
pub samples: usize,
|
||||
/// White-balanced camera RGB (with the baseline exposure) → linear Rec.2020, rows.
|
||||
matrix: [[f64; 3]; 3],
|
||||
/// Hue/saturation/value correction after `matrix` (linear ProPhoto RGB).
|
||||
pub hue_sat: Option<HsvTable>,
|
||||
}
|
||||
|
||||
impl CameraProfile {
|
||||
pub fn new(model: &str, files: usize, samples: usize, matrix: Mat3, hue_sat: Option<HsvTable>) -> CameraProfile {
|
||||
CameraProfile { version: VERSION, model: model.to_owned(), files, samples, matrix: matrix.0, hue_sat }
|
||||
}
|
||||
|
||||
pub fn matrix(&self) -> Mat3 {
|
||||
Mat3(self.matrix)
|
||||
}
|
||||
|
||||
/// Whether the data is usable (bounded matrix, table shape matching its data).
|
||||
fn valid(&self) -> bool {
|
||||
let matrix = self.matrix.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) && Mat3(self.matrix).inverse().is_some();
|
||||
let table = self.hue_sat.as_ref().is_none_or(|t| {
|
||||
let dims = (1..=4096).contains(&t.hue_divisions) && (2..=4096).contains(&t.sat_divisions) && (1..=4096).contains(&t.val_divisions);
|
||||
let len = t.hue_divisions.checked_mul(t.sat_divisions).and_then(|n| n.checked_mul(t.val_divisions));
|
||||
dims && len == Some(t.data.len()) && t.data.iter().flatten().all(|v| v.is_finite()) && t.data.iter().all(|e| e[1] >= 0.0 && e[2] >= 0.0)
|
||||
});
|
||||
self.version == VERSION && !self.model.is_empty() && matrix && table
|
||||
}
|
||||
}
|
||||
|
||||
/// LightCraft's configuration folder (settings, GPU marker, camera profiles).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where camera profiles are read from and written to.
|
||||
pub fn dir() -> Option<PathBuf> {
|
||||
std::env::var_os("LIGHTCRAFT_CAMERA_PROFILES").map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("camera-profiles")))
|
||||
}
|
||||
|
||||
/// File name of `model`'s profile: letters, digits, `-` and `_` kept, anything else `_`.
|
||||
pub fn file_name(model: &str) -> String {
|
||||
let name: String = model.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
|
||||
format!("{name}.json")
|
||||
}
|
||||
|
||||
/// Read and validate a profile file.
|
||||
pub fn load(path: &Path) -> Result<CameraProfile, String> {
|
||||
let size = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?.len();
|
||||
if size > MAX_FILE {
|
||||
return Err(format!("{}: {size} bytes is too large for a camera profile", path.display()));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
parse(&bytes, &path.display().to_string())
|
||||
}
|
||||
|
||||
/// Parse and validate a profile's JSON (`origin` names it in errors).
|
||||
fn parse(json: &[u8], origin: &str) -> Result<CameraProfile, String> {
|
||||
let profile: CameraProfile = serde_json::from_slice(json).map_err(|e| format!("{origin}: {e}"))?;
|
||||
if !profile.valid() {
|
||||
return Err(format!("{origin}: not a usable camera profile (version {}, model {:?})", profile.version, profile.model));
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// The built-in profile for `model`, if any.
|
||||
fn bundled(model: &str) -> Option<CameraProfile> {
|
||||
let (_, json) = BUNDLED.iter().find(|(m, _)| *m == model)?;
|
||||
parse(json.as_bytes(), &format!("built-in profile {model}")).inspect_err(|e| eprintln!("lightcraft: ignoring camera profile {e}")).ok()
|
||||
}
|
||||
|
||||
/// Write `profile` to `dir` (created if needed) as `<model>.json`; returns the path.
|
||||
pub fn save(profile: &CameraProfile, dir: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(file_name(&profile.model));
|
||||
let json = serde_json::to_vec_pretty(profile).map_err(|e| e.to_string())?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<String, Option<Arc<CameraProfile>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Option<Arc<CameraProfile>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The profile for camera `model`: from [`dir`], else built in; read once per process.
|
||||
pub fn get(model: &str) -> Option<Arc<CameraProfile>> {
|
||||
let model = model.trim();
|
||||
if model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut cache = cache().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(hit) = cache.get(model) {
|
||||
return hit.clone();
|
||||
}
|
||||
let path = dir().map(|d| d.join(file_name(model)));
|
||||
let profile = match path {
|
||||
Some(path) if path.is_file() => match load(&path) {
|
||||
Ok(p) if p.model.trim() == model => Some(Arc::new(p)),
|
||||
Ok(p) => {
|
||||
eprintln!("lightcraft: ignoring camera profile {}: it is for {:?}, not {model:?}", path.display(), p.model);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: ignoring camera profile {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let profile = profile.or_else(|| bundled(model).map(Arc::new));
|
||||
cache.insert(model.to_owned(), profile.clone());
|
||||
profile
|
||||
}
|
||||
|
||||
/// Changes when the profiles folder's contents change (per process): part of the render cache
|
||||
/// keys, so thumbnails rendered before a profile existed are not reused after.
|
||||
pub fn cache_key() -> u64 {
|
||||
static KEY: OnceLock<u64> = OnceLock::new();
|
||||
*KEY.get_or_init(|| {
|
||||
let entries = dir().and_then(|d| std::fs::read_dir(d).ok());
|
||||
let mut files: Vec<(String, u64, u64)> = entries
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
|
||||
Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut h = lightcraft_preview::Hasher128::new();
|
||||
for (name, len, modified) in &files {
|
||||
h.str(name).u64(*len).u64(*modified);
|
||||
}
|
||||
for (model, json) in BUNDLED {
|
||||
h.str(model).str(json);
|
||||
}
|
||||
h.finish().0 as u64
|
||||
})
|
||||
}
|
||||
|
||||
/// Per camera model: the photos read and their pooled colour pairs.
|
||||
#[derive(Default)]
|
||||
pub struct Pool {
|
||||
models: HashMap<String, (usize, Vec<([f64; 3], [f64; 3])>)>,
|
||||
}
|
||||
|
||||
/// Most colour pairs kept per photo, so a few busy photos can't dominate a profile.
|
||||
const PAIRS_PER_FILE: usize = 4000;
|
||||
|
||||
impl Pool {
|
||||
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
|
||||
/// NEF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
let entry = self.models.entry(model.to_owned()).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1.extend(pairs.into_iter().step_by(step));
|
||||
Ok(Some(model.to_owned()))
|
||||
}
|
||||
|
||||
/// Fit a profile per model with at least `min_files` photos.
|
||||
pub fn fit(&self, min_files: usize) -> Vec<Result<CameraProfile, String>> {
|
||||
let mut models: Vec<_> = self.models.iter().collect();
|
||||
models.sort_by(|a, b| a.0.cmp(b.0));
|
||||
models
|
||||
.into_iter()
|
||||
.filter(|(_, (files, _))| *files >= min_files)
|
||||
.map(|(model, (files, pairs))| {
|
||||
let (matrix, hue_sat) = crate::camera_preview::fit_profile(pairs).ok_or_else(|| format!("{model}: no usable colour fit"))?;
|
||||
Ok(CameraProfile::new(model, *files, pairs.len(), matrix, hue_sat))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add another pool's photos and pairs (pools filled on separate threads).
|
||||
pub fn merge(&mut self, other: Pool) {
|
||||
for (model, (files, pairs)) in other.models {
|
||||
let entry = self.models.entry(model).or_default();
|
||||
entry.0 += files;
|
||||
entry.1.extend(pairs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos read per model.
|
||||
pub fn files(&self) -> Vec<(String, usize)> {
|
||||
let mut v: Vec<_> = self.models.iter().map(|(m, (n, _))| (m.clone(), *n)).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile() -> CameraProfile {
|
||||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.2, 1.0]; 8], srgb_value: false };
|
||||
CameraProfile::new("ILCE-7M4", 12, 3456, Mat3([[1.6, -0.5, -0.1], [-0.2, 1.3, -0.1], [-0.1, -0.2, 1.3]]), Some(table))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_round_trip_and_reject_bad_files() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-camera-profiles-{}", std::process::id()));
|
||||
let path = save(&profile(), &dir).unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "ILCE-7M4.json");
|
||||
assert_eq!(load(&path).unwrap(), profile());
|
||||
let bad = |p: CameraProfile| {
|
||||
let path = dir.join("bad.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&p).unwrap()).unwrap();
|
||||
load(&path).is_err()
|
||||
};
|
||||
let mut p = profile();
|
||||
p.matrix[0][0] = f64::NAN;
|
||||
assert!(bad(p), "non-finite matrix");
|
||||
let mut p = profile();
|
||||
p.matrix = [[0.0; 3]; 3];
|
||||
assert!(bad(p), "singular matrix");
|
||||
let mut p = profile();
|
||||
if let Some(t) = p.hue_sat.as_mut() {
|
||||
t.data.truncate(3);
|
||||
}
|
||||
assert!(bad(p), "table data doesn't match its shape");
|
||||
let mut p = profile();
|
||||
p.version = 99;
|
||||
assert!(bad(p), "unknown version");
|
||||
std::fs::write(dir.join("junk.json"), b"{not json").unwrap();
|
||||
assert!(load(&dir.join("junk.json")).is_err());
|
||||
assert!(load(&dir.join("missing.json")).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_profiles_are_valid_and_named_after_their_model() {
|
||||
assert!(!BUNDLED.is_empty());
|
||||
for (model, json) in BUNDLED {
|
||||
let p = parse(json.as_bytes(), model).unwrap();
|
||||
assert_eq!(p.model, *model);
|
||||
assert!(p.files >= 5 && p.hue_sat.is_some(), "{model}: {} photos", p.files);
|
||||
assert_eq!(bundled(model), Some(p));
|
||||
}
|
||||
assert!(bundled("No Such Camera").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_are_safe() {
|
||||
assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json");
|
||||
assert_eq!(file_name(" DSC-RX100M3 "), "DSC-RX100M3.json");
|
||||
assert_eq!(file_name("../../etc/passwd"), "______etc_passwd.json");
|
||||
assert_eq!(file_name("Ω 1/2"), "__1_2.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_skips_files_that_cannot_contribute() {
|
||||
let mut pool = Pool::default();
|
||||
assert!(pool.add(b"not a raw file").is_err());
|
||||
assert!(pool.fit(1).is_empty());
|
||||
assert!(pool.files().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const PROBE_EDGE: usize = 384;
|
||||
pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
neutral(&mut d);
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE);
|
||||
|
||||
@@ -26,7 +26,12 @@ fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
/// written to a temp file, synced, read back identical and only then given its name — never
|
||||
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
|
||||
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match &s.media.file_bytes {
|
||||
write_dng_with(s.media.file_bytes.as_ref(), path, packet)
|
||||
}
|
||||
|
||||
/// [`write_dng_for`] without the session (imports convert on a worker thread).
|
||||
pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match file_bytes {
|
||||
Some(r) => r(path)?,
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
@@ -144,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(())
|
||||
};
|
||||
@@ -177,6 +183,33 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
|
||||
})
|
||||
}
|
||||
|
||||
/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe.
|
||||
fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
||||
let (mut m, src) = (ph.meta.clone(), &info.meta);
|
||||
let before = m.clone();
|
||||
if m.camera.is_empty() {
|
||||
m.camera = src.camera.clone();
|
||||
}
|
||||
if m.lens.is_empty() {
|
||||
m.lens = src.lens.clone();
|
||||
}
|
||||
if m.shutter.is_empty() {
|
||||
m.shutter = src.shutter.clone();
|
||||
}
|
||||
m.focal_mm = m.focal_mm.or(src.focal_mm);
|
||||
m.aperture = m.aperture.or(src.aperture);
|
||||
m.iso = m.iso.or(src.iso);
|
||||
m.gps = m.gps.or(src.gps);
|
||||
let mut ops = Vec::new();
|
||||
if m != before {
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
}
|
||||
if ph.captured.is_none() && info.captured.is_some() {
|
||||
ops.push(Op::SetCaptured { id, captured: info.captured.clone() });
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -193,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut reloaded = Vec::new();
|
||||
for ((id, _), info) in paths.into_iter().zip(probed) {
|
||||
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
|
||||
let Some(op) = content_op(id, ph, info.clone()) else { continue };
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let Some(op) = content_op(id, ph, info.clone()) else {
|
||||
if !meta_ops.is_empty() {
|
||||
ops.extend(meta_ops);
|
||||
reloaded.push(id);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ops.push(op);
|
||||
ops.extend(meta_ops);
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
@@ -271,9 +314,9 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"photo.reload",
|
||||
"Reload from Disk",
|
||||
[],
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
@@ -288,3 +331,32 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
|
||||
/// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps
|
||||
/// everything already set.
|
||||
#[test]
|
||||
fn reload_fills_only_missing_camera_fields() {
|
||||
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00");
|
||||
ph.meta.title = "Mine".into();
|
||||
ph.meta.iso = Some(800);
|
||||
let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() };
|
||||
info.meta.camera = "Canon EOS R6 Mark III".into();
|
||||
info.meta.lens = "RF24-105mm".into();
|
||||
info.meta.iso = Some(400);
|
||||
info.meta.aperture = Some(6.3);
|
||||
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
|
||||
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
|
||||
assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3)));
|
||||
assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept");
|
||||
assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. })));
|
||||
// nothing missing → nothing to do
|
||||
ph.meta = (**meta).clone();
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings| {
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
for (k, v) in &vals {
|
||||
controls::set(d, k, *v);
|
||||
if k == "wb.temp" || k == "wb.tint" {
|
||||
@@ -113,15 +117,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
apply(&mut d);
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
s.commit(&label, Op::Batch { ops })?;
|
||||
return ok();
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d);
|
||||
apply(d, &info);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -130,7 +137,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
Ok(())
|
||||
@@ -149,12 +164,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
.filter_map(|(id, _d)| {
|
||||
// back to the photo's import defaults when a default preset gave it its look
|
||||
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
|
||||
None => {
|
||||
let info = s.source_info(id);
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
})
|
||||
@@ -203,7 +224,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| {
|
||||
let id = active(s, "develop.autoBwMix")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
|
||||
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
|
||||
@@ -218,7 +239,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
|
||||
let id = active(s, "develop.auto")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let a = lightcraft_pipeline::auto::auto_tone(&src, &info, &d);
|
||||
edit(s, "develop.auto", "Auto", |d| {
|
||||
@@ -245,7 +266,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode: WbMode =
|
||||
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("custom"))).map_err(|e| bad("develop.wb", e.to_string()))?;
|
||||
let id = active(s, "develop.wb")?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let (mut t, mut tint) = match mode {
|
||||
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
|
||||
WbMode::Auto => {
|
||||
@@ -256,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.preset()
|
||||
.map(|(t, ti)| {
|
||||
// presets are relative to daylight for rendered files
|
||||
if info.raw { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
})
|
||||
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||
};
|
||||
@@ -277,7 +298,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (x, y) = (f64_req(p, "x", "develop.wbPick")?, f64_req(p, "y", "develop.wbPick")?);
|
||||
let id = active(s, "develop.wbPick")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wbPick", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
// Sample a small neighbourhood in the oriented source.
|
||||
let o = src.oriented(d.orientation);
|
||||
@@ -405,7 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| {
|
||||
let id = active(s, "crop.autoStraighten")?;
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else {
|
||||
return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"}));
|
||||
@@ -501,7 +522,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Upright::Off | Upright::Guided => None,
|
||||
m => {
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
|
||||
}
|
||||
};
|
||||
@@ -657,6 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
|
||||
let info = s.source_info(id);
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
|
||||
@@ -7,6 +7,105 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
|
||||
Ok(rd
|
||||
.flatten()
|
||||
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.filter_map(|e| {
|
||||
let m = std::fs::metadata(e.path()).ok()?;
|
||||
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `library.import`'s params, checked (no file-system calls).
|
||||
pub struct ImportRequest {
|
||||
pub paths: Vec<String>,
|
||||
pub opts: crate::import::ImportOptions,
|
||||
pub album: Option<u64>,
|
||||
pub album_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a
|
||||
/// worker thread, with the same options).
|
||||
pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
let paths = strs(p, "paths");
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
"move" => crate::import::ImportMode::Move,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
||||
};
|
||||
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
||||
Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let album = p.get("album").and_then(Value::as_u64);
|
||||
if let Some(a) = album
|
||||
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"))
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let opts = crate::import::ImportOptions {
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
destination: str_param(p, "destination").map(str::to_string),
|
||||
organize,
|
||||
rename: str_param(p, "rename").map(str::to_string),
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: bool_or(p, "dng", false),
|
||||
local: bool_or(p, "local", false),
|
||||
};
|
||||
let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
Ok(ImportRequest { paths, opts, album, album_name })
|
||||
}
|
||||
|
||||
/// After a batch of an import was committed: the album (a new one named `album_name` when there
|
||||
/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with
|
||||
/// `album`).
|
||||
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
|
||||
let mut report = serde_json::to_value(report).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
if album.is_none()
|
||||
&& let Some(name) = album_name
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
let r = s.execute("album.create", &json!({"name": name}))?;
|
||||
album = r["id"].as_u64();
|
||||
}
|
||||
if let Some(a) = album
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
// (album.addPhotos wants a selection: the new photos are about to be it)
|
||||
s.selection = Selection::single(PhotoId(imported[0]));
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
||||
report["album"] = json!(a);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
|
||||
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
|
||||
}
|
||||
@@ -125,6 +224,25 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"selected": n}))
|
||||
}
|
||||
|
||||
/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote.
|
||||
const MAX_SEED: u64 = 1 << 53;
|
||||
|
||||
/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error.
|
||||
fn seed_param(p: &Value, cmd: &str, default: u64) -> Result<u64> {
|
||||
match p.get("seed") {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles
|
||||
/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON
|
||||
/// clients that read numbers as doubles (web, MCP agents) get the same seed back.
|
||||
fn next_seed(prev: u64, now: &str) -> u64 {
|
||||
let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b)));
|
||||
(lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// ---- view source / filter / sort
|
||||
@@ -164,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Filter",
|
||||
[],
|
||||
None,
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
@@ -182,7 +300,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sort",
|
||||
["View", "Sort"],
|
||||
None,
|
||||
"{key?: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool, group?: auto|none|day|month|year}",
|
||||
"{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}",
|
||||
always,
|
||||
|s, p| {
|
||||
let key: SortKey = match p.get("key") {
|
||||
@@ -193,7 +311,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?,
|
||||
None => s.sort.group,
|
||||
};
|
||||
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group };
|
||||
let mut seed = seed_param(p, "library.sort", s.sort.seed)?;
|
||||
let explicit = p.get("seed").is_some_and(|v| !v.is_null());
|
||||
if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit {
|
||||
// switching to Random is a fresh shuffle, not whatever seed was left behind
|
||||
seed = next_seed(seed, &(s.clock)());
|
||||
}
|
||||
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed };
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.shuffle",
|
||||
"Reshuffle",
|
||||
["View", "Sort"],
|
||||
None,
|
||||
"{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time",
|
||||
always,
|
||||
|s, p| {
|
||||
let seed = match seed_param(p, "library.shuffle", s.sort.seed)? {
|
||||
given if p.get("seed").is_some_and(|v| !v.is_null()) => given,
|
||||
// derived from the previous seed and the session clock: reproducible under a test
|
||||
// clock, and no RNG needed
|
||||
prev => next_seed(prev, &(s.clock)()),
|
||||
};
|
||||
s.sort = Sort { key: SortKey::Random, seed, ..s.sort };
|
||||
ok()
|
||||
}
|
||||
),
|
||||
@@ -347,6 +489,66 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)),
|
||||
cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)),
|
||||
cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)),
|
||||
// ---- face / pet regions
|
||||
cmd!(
|
||||
"photo.removeRegion",
|
||||
"Remove Face Box",
|
||||
[],
|
||||
None,
|
||||
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
|
||||
always,
|
||||
|s, p| {
|
||||
let id =
|
||||
p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
|
||||
let index = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?;
|
||||
let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone();
|
||||
if index >= meta.regions.len() {
|
||||
return Err(bad("photo.removeRegion", "no such region"));
|
||||
}
|
||||
let gone = meta.regions.remove(index);
|
||||
s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
|
||||
s.skip_auto_write = true;
|
||||
Ok(json!({"removed": gone.name}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.setRegion",
|
||||
"Resize Face Box",
|
||||
[],
|
||||
None,
|
||||
"{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "photo.setRegion";
|
||||
// a drag is one undo step however many frames it took: the caller commits once, on release
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
||||
let index = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad(C, "missing or invalid `index`"))?;
|
||||
let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?;
|
||||
let num = |k: &str| {
|
||||
r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number")))
|
||||
};
|
||||
let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?);
|
||||
let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0));
|
||||
let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0));
|
||||
if x1 - x0 < 0.005 || y1 - y0 < 0.005 {
|
||||
return Err(bad(C, "the box would be too small"));
|
||||
}
|
||||
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
|
||||
let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?;
|
||||
region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 };
|
||||
s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
|
||||
s.skip_auto_write = true;
|
||||
Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}}))
|
||||
}
|
||||
),
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
@@ -354,11 +556,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
id,
|
||||
deleted: false
|
||||
}))),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| {
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let t = s.targets(p);
|
||||
let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::<Vec<_>>();
|
||||
s.commit("Delete Permanently", Op::Batch { ops })?;
|
||||
@@ -657,9 +859,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
|
||||
always,
|
||||
|s, _| {
|
||||
|s, p| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
// only files that stopped growing: a file still being written is left for later
|
||||
let known: std::collections::HashSet<String> = s
|
||||
@@ -670,18 +872,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
|
||||
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
|
||||
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
|
||||
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
|
||||
};
|
||||
let mut fresh = Vec::new();
|
||||
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
|
||||
let path = e.path();
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
for (ps, size) in listing {
|
||||
if known.contains(&ps) {
|
||||
continue;
|
||||
}
|
||||
// each file is tried once (a non-photo isn't retried every scan)
|
||||
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
|
||||
continue;
|
||||
}
|
||||
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let seen = s.auto_import_seen.insert(ps.clone(), size);
|
||||
if size > 0 && seen == Some(size) {
|
||||
fresh.push(ps);
|
||||
@@ -701,6 +905,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for f in &fresh {
|
||||
s.auto_import_seen.insert(f.clone(), u64::MAX);
|
||||
}
|
||||
if p.get("start").and_then(Value::as_bool) == Some(false) {
|
||||
// the caller imports them (the app: on a worker thread)
|
||||
return Ok(json!({"imported": [], "folder": folder, "import": params}));
|
||||
}
|
||||
let sel = s.selection.clone();
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
@@ -751,79 +959,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
if paths.is_empty() {
|
||||
let req = import_params(s, p)?;
|
||||
if req.paths.is_empty() {
|
||||
return Err(bad("library.import", "no paths"));
|
||||
}
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
"move" => crate::import::ImportMode::Move,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
||||
};
|
||||
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
||||
Some(id) => {
|
||||
Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut album = p.get("album").and_then(Value::as_u64);
|
||||
if let Some(a) = album
|
||||
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad(
|
||||
"library.import",
|
||||
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
|
||||
)
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let opts = crate::import::ImportOptions {
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
destination: str_param(p, "destination").map(str::to_string),
|
||||
organize,
|
||||
rename: str_param(p, "rename").map(str::to_string),
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: super::bool_or(p, "dng", false),
|
||||
local: super::bool_or(p, "local", false),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
if album.is_none()
|
||||
&& let Some(name) = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty())
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
let r = s.execute("album.create", &json!({"name": name}))?;
|
||||
album = r["id"].as_u64();
|
||||
}
|
||||
if let Some(a) = album
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
// (album.addPhotos wants a selection: the new photos are about to be it)
|
||||
s.selection = Selection::single(PhotoId(imported[0]));
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
||||
report["album"] = json!(a);
|
||||
}
|
||||
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
|
||||
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
|
||||
// one undo step for the whole import
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
let n = s.undo.len().saturating_sub(undo0);
|
||||
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
|
||||
if let Some(f) = imported.first() {
|
||||
@@ -890,6 +1034,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
|
||||
s.media.rendered.clear();
|
||||
s.media.clear_sources();
|
||||
ok()
|
||||
}),
|
||||
]
|
||||
|
||||
+273
-10
@@ -5,6 +5,7 @@ use lightcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_param};
|
||||
use crate::segment::{Click, MAX_CLICKS};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
|
||||
@@ -38,10 +39,100 @@ fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
|
||||
.unwrap_or_default();
|
||||
MaskShape::ColorRange { samples, refine: f64_or(p, "refine", 50.0) }
|
||||
}
|
||||
other => return Err(bad(c, format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange)"))),
|
||||
"object" => MaskShape::Object { hint: points(p, "points"), exclude: points(p, "exclude"), seg: seg_param(p), detail: vec![], edge: 0.0 },
|
||||
"prompt" => {
|
||||
MaskShape::Prompt { text: str_param(p, "text").unwrap_or_default().trim().to_string(), seg: seg_param(p), detail: vec![], edge: 0.0 }
|
||||
}
|
||||
other => {
|
||||
return Err(bad(
|
||||
c,
|
||||
format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt)"),
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `[[x, y], …]` normalized points under `key`.
|
||||
fn points(p: &Value, key: &str) -> Vec<Point> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A segmentation passed in (`seg`: a stored or replayed AI mask; the model isn't needed).
|
||||
fn seg_param(p: &Value) -> Option<lightcraft_develop::SegMask> {
|
||||
p.get("seg").and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// What a new AI shape still needs once it is added.
|
||||
enum Later {
|
||||
Nothing,
|
||||
/// Background mode: these Object clicks are segmented on the worker.
|
||||
Clicks(Vec<Point>, Vec<Point>),
|
||||
/// Background mode: the Describe selection is computed first; the mask is added when found.
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Compute the segmentation of a new AI shape (Object with clicks, Prompt) — or, in background
|
||||
/// mode, say what to queue. An Object without clicks stays empty until the photo is clicked
|
||||
/// (the photo is analyzed meanwhile); a Prompt that matches nothing is an error. Shapes given
|
||||
/// with their `seg` need no model.
|
||||
fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let background = s.segmenter.background;
|
||||
match shape {
|
||||
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
|
||||
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
|
||||
}
|
||||
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
|
||||
*seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
|
||||
}
|
||||
MaskShape::Object { .. } => {
|
||||
// the clicks come later: refuse an Object mask that could never be computed (no
|
||||
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
if background {
|
||||
s.segment_prepare(id).map_err(ai_err)?;
|
||||
}
|
||||
}
|
||||
MaskShape::Prompt { text, .. } if background => {
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(ai_err("describe what to select"));
|
||||
}
|
||||
return Ok(Later::Text(text.clone()));
|
||||
}
|
||||
MaskShape::Prompt { text, seg, .. } => {
|
||||
let found = s.segment_text(id, text).map_err(ai_err)?;
|
||||
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Later::Nothing)
|
||||
}
|
||||
|
||||
/// Queue what a new AI component (component `comp` of mask `mask`) still needs.
|
||||
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
|
||||
if let Later::Clicks(hint, exclude) = later {
|
||||
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
|
||||
s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
|
||||
out["pending"] = json!(true);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// AI mask failures are shown as they are (not as "invalid parameters").
|
||||
fn ai_err(msg: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
|
||||
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
|
||||
}
|
||||
|
||||
fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mask>, &mut Option<u32>) -> Result<()>) -> Result<Value> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
@@ -67,14 +158,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create New Mask",
|
||||
[],
|
||||
None,
|
||||
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…), name?}",
|
||||
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…; object: points/exclude [[x,y],…]; prompt: text; object/prompt: seg? a stored segmentation, no model needed), name?} — AI kinds need the SAM 3 model; in the app a prompt returns {pending} and the mask appears when found",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("radial").to_string();
|
||||
let shape = shape_from(&kind, p, "mask.add")?;
|
||||
let mut shape = shape_from(&kind, p, "mask.add")?;
|
||||
let later = resolve_ai(s, &mut shape, "mask.add")?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
if let Later::Text(text) = &later {
|
||||
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
|
||||
s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
masks_edit(s, "mask.add", "Add Mask", |masks, active| {
|
||||
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
|
||||
masks.push(Mask {
|
||||
id: next,
|
||||
name: name.unwrap_or_else(|| format!("Mask {next}")),
|
||||
@@ -83,7 +180,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
});
|
||||
*active = Some(next);
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
queue_later(s, later, next, 0, out)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -95,16 +193,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_active,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("brush").to_string();
|
||||
let shape = shape_from(&kind, p, "mask.addComponent")?;
|
||||
let op: MaskOp =
|
||||
serde_json::from_value(p.get("op").cloned().unwrap_or(json!("add"))).map_err(|e| bad("mask.addComponent", e.to_string()))?;
|
||||
let active = s.active_mask;
|
||||
let mid = mask_id(p, active, "mask.addComponent")?;
|
||||
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
|
||||
let id = s.active().ok_or_else(|| bad("mask.addComponent", "no active photo"))?;
|
||||
if !s.develop_of(id).is_some_and(|d| d.masks.iter().any(|m| m.id == mid)) {
|
||||
return Err(bad("mask.addComponent", format!("no mask {mid}")));
|
||||
}
|
||||
let mut shape = shape_from(&kind, p, "mask.addComponent")?;
|
||||
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
|
||||
if let Later::Text(text) = &later {
|
||||
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
|
||||
s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let mut comp = 0;
|
||||
let out = masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
|
||||
let i = find(masks, mid, "mask.addComponent")?;
|
||||
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
|
||||
comp = masks[i].components.len() - 1;
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
queue_later(s, later, mid, comp, out)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -185,7 +296,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
|
||||
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
|
||||
@@ -213,6 +324,158 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"samples": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.objectPoint",
|
||||
"Add Object Click",
|
||||
[],
|
||||
None,
|
||||
"{x, y: normalized image point, exclude?: bool (⌥-click: leave this part out), id?: maskId} — a click on the selected mask's Object selection (its last Object component); SAM 3 re-segments the object → {include, exclude}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.objectPoint";
|
||||
let at = Point::new(super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
|
||||
if !(0.0..=1.0).contains(&at.x) || !(0.0..=1.0).contains(&at.y) {
|
||||
return Err(bad(c, "the point is outside the photo"));
|
||||
}
|
||||
let exclude = bool_or(p, "exclude", false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
|
||||
let k = m
|
||||
.components
|
||||
.iter()
|
||||
.rposition(|x| matches!(x.shape, MaskShape::Object { .. }))
|
||||
.ok_or_else(|| bad(c, "the mask has no Object selection (add one first)"))?;
|
||||
let Some(MaskShape::Object { hint, exclude: ex, edge, .. }) = m.components.get(k).map(|x| &x.shape) else {
|
||||
return Err(bad(c, "not an Object component"));
|
||||
};
|
||||
let (mut hint, mut ex, edge) = (hint.clone(), ex.clone(), *edge);
|
||||
// clicks still on their way to the model count too
|
||||
if let Some(pending) = s.segmenter.pending_clicks().filter(|q| (q.photo, q.mask, q.comp) == (id, mid, k)) {
|
||||
(hint, ex) = (pending.hint.clone(), pending.exclude.clone());
|
||||
}
|
||||
if hint.len() + ex.len() >= MAX_CLICKS {
|
||||
return Err(ai_err(format!("an Object selection takes at most {MAX_CLICKS} clicks; start a new one")));
|
||||
}
|
||||
if exclude {
|
||||
ex.push(at)
|
||||
} else {
|
||||
hint.push(at)
|
||||
}
|
||||
if s.segmenter.background && !hint.is_empty() {
|
||||
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
|
||||
s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
|
||||
return Ok(out);
|
||||
}
|
||||
let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
|
||||
let out = json!({"include": hint.len(), "exclude": ex.len()});
|
||||
masks_edit(s, c, "Object Mask", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
if let Some(comp) = masks[i].components.get_mut(k) {
|
||||
comp.shape = MaskShape::Object { hint, exclude: ex, seg, detail: vec![], edge };
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.refineDetail",
|
||||
"Refine AI Mask Detail",
|
||||
[],
|
||||
None,
|
||||
"{id?: maskId, component?} — a zoomed-in SAM 3 pass over an Object/Describe selection (default: the mask's last one): the photo around it is analyzed again at a higher resolution, in the background; the mask updates when it's done → {started}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.refineDetail";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
|
||||
let k = match p.get("component").and_then(Value::as_u64) {
|
||||
Some(k) => k as usize,
|
||||
None => m
|
||||
.components
|
||||
.iter()
|
||||
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
|
||||
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
|
||||
};
|
||||
let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
|
||||
Ok(json!({"started": started}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.prepare",
|
||||
"Prepare AI Masks",
|
||||
[],
|
||||
None,
|
||||
"{} — load SAM 3 and analyze the active photo in the background, so Object clicks are instant → {busy}",
|
||||
has_active,
|
||||
|s, _| {
|
||||
let c = "segment.prepare";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
s.segment_prepare(id).map_err(ai_err)?;
|
||||
Ok(json!({"busy": s.segmenter.busy()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.model.status",
|
||||
"AI Mask Model Status",
|
||||
[],
|
||||
None,
|
||||
"{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",
|
||||
super::always,
|
||||
|s, _| {
|
||||
let g = &s.segmenter;
|
||||
Ok(json!({
|
||||
"available": crate::segment::Segmenter::AVAILABLE,
|
||||
"installed": g.installed(),
|
||||
"dir": g.dir.as_ref().map(|d| d.display().to_string()),
|
||||
"loaded": g.loaded(),
|
||||
"busy": g.busy(),
|
||||
"analyzing": g.analyzing(),
|
||||
"sizeBytes": crate::segment::MODEL_BYTES,
|
||||
"license": crate::segment::LICENSE_NAME,
|
||||
"licenseUrl": crate::segment::LICENSE_URL,
|
||||
"mirrors": g.mirrors().len(),
|
||||
"download": g.download_status(),
|
||||
}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.model.download",
|
||||
"Download AI Mask Model",
|
||||
[],
|
||||
None,
|
||||
"{acknowledged: true} — download the SAM 3 model (about 3.4 GB, Meta's SAM License, not LightCraft's) in the background, from the configured mirrors; only after the user agreed to it. Watch segment.model.status; segment.model.cancel stops it (it resumes later) → {started, installed, downloading}",
|
||||
super::always,
|
||||
|s, p| {
|
||||
let c = "segment.model.download";
|
||||
if p.get("acknowledged").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(bad(
|
||||
c,
|
||||
format!(
|
||||
"the SAM 3 model is a {:.1} GB download under Meta's {} ({}): pass `acknowledged: true` once the user has agreed to download it",
|
||||
crate::segment::MODEL_BYTES as f64 / 1e9,
|
||||
crate::segment::LICENSE_NAME,
|
||||
crate::segment::LICENSE_URL
|
||||
),
|
||||
));
|
||||
}
|
||||
let started = s.segmenter.start_download().map_err(ai_err)?;
|
||||
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "segment.model.cancel",
|
||||
"Cancel AI Mask Model Download",
|
||||
[],
|
||||
None,
|
||||
"{} — stop the SAM 3 download (what has arrived is kept, and a new download resumes from it) → {cancelled}",
|
||||
super::always,
|
||||
|s, _| Ok(json!({"cancelled": s.segmenter.cancel_download()}))
|
||||
),
|
||||
cmd!(
|
||||
"mask.refine",
|
||||
"Refine Mask Edges",
|
||||
@@ -646,7 +909,7 @@ fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spo
|
||||
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
|
||||
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let info = crate::media::source_info(s.catalog.photo(id)?);
|
||||
let info = s.source_info(id);
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,10 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
}
|
||||
|
||||
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
|
||||
/// the photos its query already narrowed to).
|
||||
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
|
||||
/// the photos its query already narrowed to). In the app `avail` answers from its cache (files
|
||||
/// not checked yet count as present until the background check finds them gone).
|
||||
pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
@@ -94,8 +95,112 @@ fn file_hash(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
|
||||
/// undoable step.
|
||||
/// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and
|
||||
/// content hash the library knows (0 / `None` = unknown).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FindCandidate {
|
||||
pub id: PhotoId,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Every in-scope photo, for [`plan_find_missing`]: the missing ones are looked for, and no
|
||||
/// photo's file is used for another. No file-system calls, so the app takes this snapshot on the
|
||||
/// UI thread and plans on a worker without holding the session.
|
||||
pub fn find_candidates(cat: &Catalog) -> Vec<FindCandidate> {
|
||||
cat.photos()
|
||||
.filter_map(|p| {
|
||||
checked_path(p).map(|f| FindCandidate { id: p.id, path: f.to_string(), size: p.file_size, hash: stored_hash(p).map(str::to_string) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How a missing photo's file was recognised.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MatchBy {
|
||||
/// Same name and size (and content, when the library knows its hash).
|
||||
Name,
|
||||
/// Renamed: same size and content hash.
|
||||
Content,
|
||||
}
|
||||
|
||||
impl MatchBy {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MatchBy::Name => "name",
|
||||
MatchBy::Content => "content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing photo and the file found for it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FoundFile {
|
||||
pub id: PhotoId,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub by: MatchBy,
|
||||
}
|
||||
|
||||
/// A missing photo with several look-alike files (same name and size, no hash to tell them
|
||||
/// apart): skipped, never guessed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AmbiguousFile {
|
||||
pub id: PhotoId,
|
||||
pub from: String,
|
||||
pub candidates: Vec<String>,
|
||||
}
|
||||
|
||||
/// What Find Missing Photos found ([`plan_find_missing`]), before relinking.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FindPlan {
|
||||
pub found: Vec<FoundFile>,
|
||||
/// In-scope photos that stay missing (the ambiguous ones included).
|
||||
pub missing: usize,
|
||||
pub ambiguous: Vec<AmbiguousFile>,
|
||||
}
|
||||
|
||||
impl FindPlan {
|
||||
/// `{found: [{id, from, to, by}], missing, ambiguous: [{id, from, candidates}]}` — the
|
||||
/// command's result, and what `library.findMissing {found, …}` takes back.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let found: Vec<Value> = self.found.iter().map(|f| json!({"id": f.id.0, "from": f.from, "to": f.to, "by": f.by.as_str()})).collect();
|
||||
let ambiguous: Vec<Value> = self.ambiguous.iter().map(|a| json!({"id": a.id.0, "from": a.from, "candidates": a.candidates})).collect();
|
||||
json!({"found": found, "missing": self.missing, "ambiguous": ambiguous})
|
||||
}
|
||||
|
||||
/// The inverse of [`FindPlan::to_json`] (entries it can't read are dropped).
|
||||
pub fn from_json(v: &Value) -> FindPlan {
|
||||
let list = |k: &str| v.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let found = list("found")
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let by = if f.get("by").and_then(Value::as_str) == Some("content") { MatchBy::Content } else { MatchBy::Name };
|
||||
Some(FoundFile {
|
||||
id: PhotoId(f.get("id")?.as_u64()?),
|
||||
from: str_param(f, "from")?.to_string(),
|
||||
to: str_param(f, "to")?.to_string(),
|
||||
by,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ambiguous = list("ambiguous")
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let candidates = a.get("candidates")?.as_array()?.iter().filter_map(|c| c.as_str().map(str::to_string)).collect();
|
||||
Some(AmbiguousFile { id: PhotoId(a.get("id")?.as_u64()?), from: str_param(a, "from")?.to_string(), candidates })
|
||||
})
|
||||
.collect();
|
||||
let missing = v.get("missing").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
FindPlan { found, missing, ambiguous }
|
||||
}
|
||||
}
|
||||
|
||||
/// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in
|
||||
/// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app
|
||||
/// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes
|
||||
/// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`.
|
||||
///
|
||||
/// Matching, per photo:
|
||||
/// 1. a file with the same name (any letter case), the same size and the same content hash;
|
||||
@@ -107,20 +212,21 @@ fn file_hash(path: &str) -> Option<String> {
|
||||
///
|
||||
/// Files that are already some photo's original, or that another photo was just matched with,
|
||||
/// are not used.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::result::Result<FindPlan, String> {
|
||||
if !Path::new(folder).is_dir() {
|
||||
return Err(bad(C, format!("{folder}: not a folder")));
|
||||
return Err(format!("{folder}: not a folder"));
|
||||
}
|
||||
let lost = missing(s);
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
let lost: Vec<&FindCandidate> = candidates.iter().filter(|c| !Path::new(&c.path).exists()).collect();
|
||||
if lost.is_empty() {
|
||||
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
|
||||
let in_use: HashSet<&str> = candidates.iter().map(|c| c.path.as_str()).collect();
|
||||
// file name (lower case) → candidate paths; size → paths (filled lazily)
|
||||
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f.as_str())).collect();
|
||||
for f in &files {
|
||||
if let Some(n) = Path::new(f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
|
||||
@@ -131,24 +237,21 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
|
||||
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
let mut ambiguous = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = ph.file_size;
|
||||
let hash = stored_hash(ph).map(str::to_string);
|
||||
let mut plan = FindPlan::default();
|
||||
for c in &lost {
|
||||
let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = c.size;
|
||||
let hash = c.hash.as_deref();
|
||||
let named: Vec<&String> = by_name
|
||||
.get(&name)
|
||||
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.map(|v| v.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.unwrap_or_default();
|
||||
// 1. same name and size, confirmed by the content hash
|
||||
let mut hit: Option<(String, &str)> =
|
||||
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
|
||||
let mut hit: Option<(String, MatchBy)> =
|
||||
hash.and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), MatchBy::Name));
|
||||
// 2. renamed: same size and content
|
||||
if hit.is_none()
|
||||
&& let Some(h) = &hash
|
||||
&& let Some(h) = hash
|
||||
&& size > 0
|
||||
{
|
||||
let sizes = by_size.get_or_insert_with(|| {
|
||||
@@ -162,36 +265,75 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
hit = sizes
|
||||
.get(&size)
|
||||
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
|
||||
.map(|f| (f.clone(), "content"));
|
||||
.and_then(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
|
||||
.map(|f| (f.clone(), MatchBy::Content));
|
||||
}
|
||||
// 3. same name and size only (no hash, or the file was edited in place): one candidate
|
||||
// is taken, several are ambiguous and skipped rather than guessed
|
||||
if hit.is_none() {
|
||||
match named.len() {
|
||||
1 => hit = Some((named[0].clone(), "name")),
|
||||
0 => {}
|
||||
match named.as_slice() {
|
||||
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
|
||||
[] => {}
|
||||
_ => {
|
||||
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
|
||||
plan.ambiguous.push(AmbiguousFile { id: c.id, from: c.path.clone(), candidates: named.iter().map(|f| (*f).clone()).collect() });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((path, by)) = hit else { continue };
|
||||
ops.push(relink_op(*id, &path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
|
||||
used.insert(path);
|
||||
used.insert(path.clone());
|
||||
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops })?;
|
||||
plan.missing = lost.len() - plan.found.len();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Relink what [`plan_find_missing`] found, in one undoable step, re-checked against the
|
||||
/// catalog as it is now (the search may have run on a worker while the library changed): a
|
||||
/// photo that no longer points at the path it was missing from (relinked, removed) is left
|
||||
/// alone, and a file that is now some photo's original — or that the plan names twice — is not
|
||||
/// used (that photo stays missing). `missing` is the plan's count, corrected for what changed
|
||||
/// among the photos the plan names.
|
||||
fn commit_find(s: &mut Session, plan: FindPlan) -> Result<Value> {
|
||||
let still_at = |cat: &Catalog, id: PhotoId, from: &str| cat.photo(id).and_then(|p| checked_path(p)) == Some(from);
|
||||
let mut in_use: HashSet<String> = candidates(&s.catalog).into_iter().collect();
|
||||
let mut found = Vec::new();
|
||||
let mut missing = plan.missing;
|
||||
for f in plan.found {
|
||||
if !still_at(&s.catalog, f.id, &f.from) {
|
||||
continue;
|
||||
}
|
||||
if in_use.contains(&f.to) {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
in_use.insert(f.to.clone());
|
||||
found.push(f);
|
||||
}
|
||||
let planned = plan.ambiguous.len();
|
||||
let ambiguous: Vec<AmbiguousFile> = plan.ambiguous.into_iter().filter(|a| still_at(&s.catalog, a.id, &a.from)).collect();
|
||||
// ambiguous photos relinked meanwhile aren't missing any more
|
||||
missing = missing.saturating_sub(planned - ambiguous.len());
|
||||
if !found.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops: found.iter().map(|f| relink_op(f.id, &f.to)).collect() })?;
|
||||
for f in &found {
|
||||
if let Some(id) = f["id"].as_u64() {
|
||||
s.media.forget(PhotoId(id));
|
||||
}
|
||||
s.media.forget(f.id);
|
||||
}
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
|
||||
Ok(FindPlan { found, missing, ambiguous }.to_json())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file ([`plan_find_missing`]) and
|
||||
/// relink every match in one undoable step. With `found` instead (what a search on a worker
|
||||
/// thread returned, as `{found, missing, ambiguous}`), only the relinking is done.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
if p.get("found").is_some_and(Value::is_array) {
|
||||
return commit_find(s, FindPlan::from_json(p));
|
||||
}
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
let plan = plan_find_missing(&find_candidates(&s.catalog), folder).map_err(|e| bad(C, e))?;
|
||||
commit_find(s, plan)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -213,7 +355,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Missing Photos",
|
||||
[],
|
||||
None,
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content; or {found, missing, ambiguous} — relink what a search already found (the app searches on a worker thread; photos relinked meanwhile and files now in use are skipped) → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
always,
|
||||
find_missing
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ mod edit;
|
||||
mod export;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
mod library;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
mod masks;
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct PreviewBuild {
|
||||
pub failed: AtomicUsize,
|
||||
pub cancel: AtomicBool,
|
||||
pub finished: AtomicBool,
|
||||
/// Damaged smart previews rebuilt (Build Smart Previews only).
|
||||
pub repaired: AtomicUsize,
|
||||
/// What is built ("" = previews; "smart previews" — Build Smart Previews runs the same way).
|
||||
pub what: &'static str,
|
||||
/// Why the whole build failed (e.g. the smart previews folder isn't there).
|
||||
pub error: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl PreviewBuild {
|
||||
@@ -33,8 +39,15 @@ impl PreviewBuild {
|
||||
"failed": self.failed.load(Ordering::Relaxed),
|
||||
"running": !self.finished.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancel.load(Ordering::Relaxed),
|
||||
"what": if self.what.is_empty() { "previews" } else { self.what },
|
||||
"repaired": self.repaired.load(Ordering::Relaxed),
|
||||
"error": self.error(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
@@ -94,56 +107,136 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state.json())
|
||||
}
|
||||
|
||||
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
|
||||
/// how to load the original at preview size).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
|
||||
|
||||
/// What Build / Discard Smart Previews did.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SmartCounts {
|
||||
/// Proxies there now (complete ones kept, missing or damaged ones written).
|
||||
built: usize,
|
||||
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
|
||||
repaired: usize,
|
||||
removed: usize,
|
||||
/// `[[id, why]]`
|
||||
failed: Vec<Value>,
|
||||
}
|
||||
|
||||
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
|
||||
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
|
||||
/// recreated. With `state`, progress and cancel.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_run(
|
||||
dir: &std::path::Path,
|
||||
custom: bool,
|
||||
discard: bool,
|
||||
jobs: Vec<SmartJob>,
|
||||
state: Option<&PreviewBuild>,
|
||||
) -> std::result::Result<SmartCounts, String> {
|
||||
let mut n = SmartCounts::default();
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if custom {
|
||||
crate::smart::check_writable(dir)?;
|
||||
} else {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
for (id, path, source) in jobs {
|
||||
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
|
||||
break;
|
||||
}
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
}
|
||||
} else {
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
let damaged = path.exists();
|
||||
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;
|
||||
// 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())
|
||||
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
if damaged {
|
||||
n.repaired += 1;
|
||||
if let Some(st) = state {
|
||||
st.repaired.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
n.failed.push(json!([id.0, e]));
|
||||
if let Some(st) = state {
|
||||
st.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(st) = state {
|
||||
st.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
|
||||
/// photos editable while their originals are offline.
|
||||
/// photos editable while their originals are offline. With `background` (the app) the files are
|
||||
/// read and written on a worker thread; progress like Build Previews (`library.previewProgress`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviews";
|
||||
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if s.smart_previews_dir.is_some() {
|
||||
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);
|
||||
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let mut jobs: Vec<SmartJob> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => {
|
||||
built += 1;
|
||||
repaired += usize::from(damaged);
|
||||
}
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source));
|
||||
}
|
||||
Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed}))
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
let what = if discard { "discarding smart previews" } else { "smart previews" };
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-previews".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
|
||||
log::warn!("{C}: {e}");
|
||||
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
}
|
||||
st.finished.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
return Ok(state.json());
|
||||
}
|
||||
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
@@ -225,7 +318,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Build Smart Previews",
|
||||
[],
|
||||
None,
|
||||
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
|
||||
"{ids?, discard?: bool, background?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}; with background (the app's menu) the files are read and written on a worker thread → {total, done, failed, repaired, running, what} like library.buildPreviews (progress: library.previewProgress, stop: library.cancelPreviews)",
|
||||
always,
|
||||
smart
|
||||
),
|
||||
@@ -256,7 +349,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
build
|
||||
),
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, repaired, running, cancelled, what, error} | null", always, |s, _| {
|
||||
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
|
||||
}),
|
||||
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
|
||||
|
||||
+84
-29
@@ -244,14 +244,24 @@ 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 CJK faces (Mincho first, the watermark's
|
||||
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
|
||||
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
|
||||
/// missing-glyph box.
|
||||
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
|
||||
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
|
||||
cjk.sort_by_key(|f| !f.is_mincho());
|
||||
std::iter::once(WATERMARK_FONT)
|
||||
.chain(cjk.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 +274,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 +292,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 +310,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 +349,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 +1149,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 +1364,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);
|
||||
|
||||
+73
-11
@@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||||
creator: m.artist.clone().unwrap_or_default(),
|
||||
keywords: m.keywords.clone(),
|
||||
regions: m.regions.clone(),
|
||||
};
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
@@ -131,7 +132,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||||
let as_shot_wb = Some((t.round(), tint.round()));
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
@@ -221,13 +224,16 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
drop(bytes);
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let binned = match bin_factor(&raw, max_edge) {
|
||||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||||
None => None,
|
||||
@@ -242,21 +248,28 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
let m = t.matrix.to_f32();
|
||||
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
|
||||
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],
|
||||
];
|
||||
let rgb = match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
// after the baseline exposure, as when it was fitted
|
||||
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
@@ -276,7 +289,10 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
|
||||
let camera_tone = camera_look.as_ref().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 }));
|
||||
}
|
||||
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);
|
||||
@@ -285,6 +301,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 {
|
||||
@@ -390,6 +417,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,115 @@
|
||||
//! 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 and Simplified-Chinese 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")
|
||||
}
|
||||
|
||||
/// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a
|
||||
/// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional
|
||||
/// characters too (with mainland glyph forms; still closer than the Japanese faces).
|
||||
pub fn serves(&self, script: &str) -> bool {
|
||||
self.covers(script) || (script == "Hant" && self.covers("Hans"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"))
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for any CJK script: what any CJK text needs, regardless of language.
|
||||
pub fn cjk(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
|
||||
fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script)))
|
||||
}
|
||||
|
||||
/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK
|
||||
/// scripts), which Inter has no glyphs for.
|
||||
pub fn is_cjk_script(script: &str) -> bool {
|
||||
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
|
||||
}
|
||||
|
||||
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
|
||||
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
|
||||
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
|
||||
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
|
||||
/// Empty without craft-fonts.
|
||||
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
|
||||
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
|
||||
faces.sort_by_key(|font| {
|
||||
(
|
||||
// The language's own script first. A face dedicated to the script is preferred over a
|
||||
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
|
||||
!font.serves(script),
|
||||
font.scripts.len() != 1,
|
||||
font.family != "BIZ UDPGothic",
|
||||
font.style != style,
|
||||
font.is_mincho(),
|
||||
)
|
||||
});
|
||||
faces
|
||||
}
|
||||
|
||||
/// Whether the font is for any CJK script.
|
||||
fn covers_cjk(font: &CraftFont) -> bool {
|
||||
font.scripts.iter().any(|script| is_cjk_script(script))
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
|
||||
/// text still reaches Inter before any CJK fallback.
|
||||
#[test]
|
||||
fn a_language_gets_its_own_script_first() {
|
||||
static FACES: &[CraftFont] = &[
|
||||
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
|
||||
];
|
||||
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
|
||||
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
|
||||
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
|
||||
// No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones.
|
||||
assert_eq!(first("Hant"), Some("Noto Sans CJK SC"));
|
||||
// Every CJK face is still offered to every language.
|
||||
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
|
||||
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
|
||||
}
|
||||
}
|
||||
+386
-193
@@ -32,8 +32,8 @@ use crate::media::ProbeInfo;
|
||||
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp",
|
||||
"heic", "avif",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -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
|
||||
@@ -390,7 +393,7 @@ pub struct ScanInput {
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
skip: Option<PathBuf>,
|
||||
/// path → (photo, its summary) for files already in the library.
|
||||
by_path: HashMap<String, (PhotoId, ImportCandidate)>,
|
||||
by_path: HashMap<PathBuf, (PhotoId, ImportCandidate)>,
|
||||
by_hash: HashMap<String, PhotoId>,
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
@@ -429,7 +432,7 @@ impl ScanInput {
|
||||
existing: Some(p.id.0),
|
||||
..Default::default()
|
||||
};
|
||||
by_path.insert(path.clone(), (p.id, c));
|
||||
by_path.insert(PathBuf::from(path), (p.id, c));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
@@ -455,7 +458,7 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(f.as_str())).cloned().collect();
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(Path::new(f))).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
// probes from a preceding `scan` are reused when the file is unchanged (same size)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
@@ -477,7 +480,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
let mut out = Vec::with_capacity(files.len());
|
||||
let mut kept = HashMap::new();
|
||||
for f in files {
|
||||
if let Some((_, c)) = input.by_path.get(&f) {
|
||||
if let Some((_, c)) = input.by_path.get(Path::new(&f)) {
|
||||
let mut c = c.clone();
|
||||
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
out.push(c);
|
||||
@@ -517,205 +520,392 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
|
||||
}
|
||||
|
||||
/// Import files/folders with options (preset, keywords). See the module docs.
|
||||
///
|
||||
/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars,
|
||||
/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can
|
||||
/// run the first on a worker thread; here both run in turn.
|
||||
pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result<ImportReport> {
|
||||
let mode = opts.mode;
|
||||
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
|
||||
// added file in its own storage already, so "copy" there means "add".
|
||||
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
|
||||
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
if mode == ImportMode::Move && opts.local {
|
||||
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut seq = opts.rename_start.max(1);
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
|
||||
let mut job = ImportJob::new(s, opts.clone())?;
|
||||
let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false));
|
||||
// probes a scan left for files not imported now stay for a later import
|
||||
s.import_probes.extend(std::mem::take(&mut job.cache));
|
||||
commit_prepared(s, &job.opts, &job.now, prepared)
|
||||
}
|
||||
|
||||
// existing paths and content hashes
|
||||
let mut by_path: HashMap<&str, PhotoId> = HashMap::new();
|
||||
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.as_str(), p.id);
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let mut todo = Vec::new();
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
let mut promote = Vec::new();
|
||||
for f in files {
|
||||
match by_path.get(f.as_str()) {
|
||||
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
|
||||
promote.push(Op::SetLocal { id: *id, local: false });
|
||||
report.imported.push(id.0);
|
||||
/// Everything the file-system half of an import needs, detached from the session so it can run
|
||||
/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`];
|
||||
/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting,
|
||||
/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session.
|
||||
pub struct ImportJob {
|
||||
pub opts: ImportOptions,
|
||||
/// The mode in effect (Copy / Move become Add in a library without its own folder).
|
||||
mode: ImportMode,
|
||||
lib_dir: Option<PathBuf>,
|
||||
copy_root: Option<PathBuf>,
|
||||
/// path → (photo, a Local browse record?)
|
||||
by_path: HashMap<String, (PhotoId, bool)>,
|
||||
/// content hash → the photo that has it (`None`: a file earlier in this import)
|
||||
by_hash: HashMap<String, Option<PhotoId>>,
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// Probes from a preceding scan (not read again).
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
naming: crate::sidecar::SidecarNaming,
|
||||
now: String,
|
||||
seq: usize,
|
||||
/// Files found so far (folders expanded) and handled so far, for progress.
|
||||
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Prepared {
|
||||
pub scanned: usize,
|
||||
items: Vec<PreparedItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PreparedItem {
|
||||
/// A Local (browsed) record imported for real.
|
||||
Promote(PhotoId),
|
||||
Duplicate {
|
||||
path: String,
|
||||
existing: Option<PhotoId>,
|
||||
reason: &'static str,
|
||||
hash: Option<String>,
|
||||
},
|
||||
Failed(String, String),
|
||||
Kept(Kept),
|
||||
Ready(Box<ReadyFile>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ReadyFile {
|
||||
path: String,
|
||||
stored: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
placed: Option<crate::import_move::Placed>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Undo what the batch did on disk that only a commit would make permanent: files placed by a
|
||||
/// Move (their sources were never touched). Copies stay, like copies of a failed commit.
|
||||
pub fn rollback(&self) {
|
||||
for it in &self.items {
|
||||
if let PreparedItem::Ready(r) = it
|
||||
&& let Some(pl) = &r.placed
|
||||
{
|
||||
crate::import_move::rollback(pl);
|
||||
}
|
||||
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let now = (s.clock)();
|
||||
let mut ops = promote;
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
let info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
report.failed.push((path, e));
|
||||
/// Entries in the batch (after expanding folders).
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
/// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes
|
||||
/// the probes a preceding scan left ([`Session::import_probes`]).
|
||||
pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result<Self> {
|
||||
let mode = opts.mode;
|
||||
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of
|
||||
// every added file in its own storage already, so "copy" there means "add".
|
||||
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
|
||||
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
if mode == ImportMode::Move && opts.local {
|
||||
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
Ok(ImportJob {
|
||||
seq: opts.rename_start.max(1),
|
||||
opts,
|
||||
mode,
|
||||
lib_dir,
|
||||
copy_root,
|
||||
by_path,
|
||||
by_hash,
|
||||
probe: s.media.file_probe.clone(),
|
||||
file_bytes: s.media.file_bytes.clone(),
|
||||
cache: std::mem::take(&mut s.import_probes),
|
||||
naming: s.xmp.naming,
|
||||
now: (s.clock)(),
|
||||
total: Default::default(),
|
||||
done: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
}
|
||||
|
||||
/// Expand `paths` (folders recursively, the library's own folder skipped) into the files an
|
||||
/// import would handle; counted in [`ImportJob::total`].
|
||||
pub fn expand(&self, paths: &[String]) -> Vec<String> {
|
||||
let files = expand(paths, self.lib_dir.as_deref());
|
||||
self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
files
|
||||
}
|
||||
|
||||
/// When the import started ([`Session::clock`]), for [`commit_prepared`].
|
||||
pub fn now(&self) -> &str {
|
||||
&self.now
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
let mut todo = Vec::new();
|
||||
for f in files {
|
||||
match self.by_path.get(f.as_str()).copied() {
|
||||
Some((id, true)) if !opts.local => {
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
self.by_path.insert(f, (id, false));
|
||||
out.items.push(PreparedItem::Promote(id));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
}
|
||||
Some((id, _)) => {
|
||||
out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
}
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(id) = by_hash.get(h)
|
||||
{
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
});
|
||||
let mut info = info;
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let stored = match (mode, ©_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
let n = crate::rename::expand(t, &q, self.seq);
|
||||
self.seq += 1;
|
||||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
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(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng
|
||||
&& mode == ImportMode::Copy
|
||||
&& stored != path
|
||||
&& info.kind == MediaKind::Raw
|
||||
&& !info.format.eq_ignore_ascii_case("DNG")
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
let n = crate::rename::expand(t, &q, seq);
|
||||
seq += 1;
|
||||
n
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
|
||||
/// taken back.
|
||||
pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result<ImportReport> {
|
||||
let now = now.to_string();
|
||||
let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() };
|
||||
let mut ops = Vec::new();
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
// photos added by this batch, by content (a duplicate of a file earlier in the import names it)
|
||||
let mut new_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for it in prepared.items {
|
||||
match it {
|
||||
PreparedItem::Promote(id) => {
|
||||
if s.catalog.photo(id).is_some_and(|p| p.local) {
|
||||
ops.push(Op::SetLocal { id, local: false });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
}
|
||||
PreparedItem::Duplicate { path, existing, reason, hash } => {
|
||||
let existing = existing.or_else(|| {
|
||||
let h = hash?;
|
||||
new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed.push(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
|
||||
}
|
||||
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
|
||||
PreparedItem::Kept(k) => report.kept.push(k),
|
||||
PreparedItem::Ready(r) => {
|
||||
let ReadyFile { path, stored, info, sidecar, placed: pl } = *r;
|
||||
placed.extend(pl);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
new_hash.insert(h.clone(), id);
|
||||
}
|
||||
let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default());
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
for k in &opts.keywords {
|
||||
let k = lightcraft_catalog::keywords::clean(k);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
p.meta.keywords.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
by_hash.insert(h.clone(), id);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
|
||||
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
if let Some(preset) = &opts.preset {
|
||||
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
|
||||
let label = format!("Preset: {}", preset.name);
|
||||
p.develop = d.clone();
|
||||
p.edited = Some(now.clone());
|
||||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
if opts.local {
|
||||
// the state as browsed: a later change keeps the record from being forgotten
|
||||
p.set_local_baseline();
|
||||
}
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
}
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
for k in &opts.keywords {
|
||||
let k = lightcraft_catalog::keywords::clean(k);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
p.meta.keywords.push(k);
|
||||
}
|
||||
}
|
||||
if let Some(preset) = &opts.preset {
|
||||
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
|
||||
let label = format!("Preset: {}", preset.name);
|
||||
p.develop = d.clone();
|
||||
p.edited = Some(now.clone());
|
||||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
if opts.local {
|
||||
// the state as browsed: a later change keeps the record from being forgotten
|
||||
p.set_local_baseline();
|
||||
}
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
let log0 = s.pending_log.len();
|
||||
if !ops.is_empty()
|
||||
@@ -803,6 +993,9 @@ impl Session {
|
||||
let source = self.media.origin_ref(&p.source, level.max_edge());
|
||||
let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64;
|
||||
let small = crate::media::RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: None,
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -819,7 +1012,7 @@ impl Session {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
_ => None,
|
||||
};
|
||||
Some(crate::media::QuickJob { photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) })
|
||||
Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) })
|
||||
}
|
||||
|
||||
/// Use the system clock for import/edit times (native hosts).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
//! run them off the UI thread.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod availability;
|
||||
mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
pub mod cmd;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
@@ -17,6 +20,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;
|
||||
@@ -29,6 +33,7 @@ pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
mod view;
|
||||
@@ -36,6 +41,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};
|
||||
@@ -121,6 +127,9 @@ pub struct Session {
|
||||
pub undo: Vec<UndoEntry>,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
|
||||
pub(crate) skip_auto_write: bool,
|
||||
/// Copied develop settings (partial JSON) for Paste.
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
@@ -146,6 +155,8 @@ pub struct Session {
|
||||
depth: u32,
|
||||
/// Selected mask (Masking panel), by mask id.
|
||||
pub active_mask: Option<u32>,
|
||||
/// AI masks (SAM 3): the model and the last photo prepared for it.
|
||||
pub segmenter: segment::Segmenter,
|
||||
/// Selected spot (Remove panel), by index into the active photo's spots.
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
@@ -219,6 +230,7 @@ impl Session {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
@@ -233,6 +245,7 @@ impl Session {
|
||||
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
|
||||
depth: 0,
|
||||
active_mask: None,
|
||||
segmenter: segment::Segmenter::default(),
|
||||
active_spot: None,
|
||||
library: None,
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
@@ -272,11 +285,22 @@ impl Session {
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
}
|
||||
|
||||
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
|
||||
/// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is
|
||||
/// `NotSaved`). Not journaled. The app's import task commits its batches this way.
|
||||
pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
self.run_command(id, None, f)
|
||||
}
|
||||
|
||||
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let log_start = self.pending_log.len();
|
||||
let was_active = self.active();
|
||||
self.depth += 1;
|
||||
// last-resort guard: a panic in a command is that command's error, not a crash
|
||||
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
self.depth -= 1;
|
||||
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
|
||||
self.previous_active = was_active;
|
||||
@@ -284,14 +308,20 @@ impl Session {
|
||||
self.auto_version(left);
|
||||
}
|
||||
}
|
||||
if r.is_ok() && spec.journal && self.depth == 0 {
|
||||
if r.is_ok()
|
||||
&& let Some(params) = journal
|
||||
&& self.depth == 0
|
||||
{
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..1000);
|
||||
}
|
||||
}
|
||||
if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
|
||||
self.auto_write_sidecars(&self.pending_log[log_start..]);
|
||||
if self.depth == 0 {
|
||||
let skip = std::mem::take(&mut self.skip_auto_write);
|
||||
if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
|
||||
self.auto_write_sidecars(&self.pending_log[log_start..]);
|
||||
}
|
||||
}
|
||||
if self.depth == 0 && self.library.is_some() {
|
||||
// Make the command durable before reporting success. When the command's own records
|
||||
@@ -560,7 +590,11 @@ impl Session {
|
||||
|
||||
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
|
||||
// the view fills in as the background checks find files gone
|
||||
key.1.push_str(&format!("|{}", self.media.availability.generation()));
|
||||
}
|
||||
if self.visible_key.as_ref() != Some(&key) {
|
||||
// "in the last N days" rules count back from the session's clock
|
||||
lightcraft_catalog::rules::set_now(Some((self.clock)()));
|
||||
@@ -595,8 +629,8 @@ impl Session {
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
// only the photos the query kept (library photos, not Local browse records) are checked
|
||||
let cat = &self.catalog;
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
let (cat, avail) = (&self.catalog, &self.media.availability);
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, avail, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
visible = self.catalog.arrange_stacks(&visible);
|
||||
@@ -693,6 +727,8 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_segment;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! ```text
|
||||
//! LightCraft Library/
|
||||
//! catalog.snap catalog.log (lightcraft-catalog journal)
|
||||
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
|
||||
//! presets.json view.json (user presets + favourites; last source/sort/selection)
|
||||
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
|
||||
//! thumbs/ (rendered thumbnail cache, safe to delete)
|
||||
//! Originals/ (photos imported with "copy into library")
|
||||
@@ -102,7 +102,8 @@ struct PresetsFile {
|
||||
struct ViewFile {
|
||||
source: LibrarySource,
|
||||
browse: Option<crate::Browse>,
|
||||
filter: lightcraft_catalog::Filter,
|
||||
// No filter: a library opens unfiltered. A date, keyword or person left over from the last session
|
||||
// would silently hide photos, with only a small badge to say so.
|
||||
sort: lightcraft_catalog::Sort,
|
||||
selection: Selection,
|
||||
}
|
||||
@@ -294,7 +295,13 @@ impl Session {
|
||||
{
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
let (mut journal, catalog, report) = Journal::open(catalog)?;
|
||||
let (mut journal, mut catalog, report) = Journal::open(catalog)?;
|
||||
// A loaded catalog counts revisions from 0, like every other one. Caches (sidebar counts,
|
||||
// keyword tree, the grid's list…) are keyed on the revision, so give each library loaded
|
||||
// in this process its own range: otherwise an empty library opened after one that was
|
||||
// never edited this session would look unchanged and show the old library's numbers.
|
||||
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
|
||||
self.catalog = catalog;
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
@@ -346,7 +353,6 @@ impl Session {
|
||||
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.filter = v.filter;
|
||||
self.sort = v.sort;
|
||||
self.selection = v.selection;
|
||||
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
|
||||
@@ -494,17 +500,11 @@ impl Session {
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
let view = ViewFile {
|
||||
source: self.source,
|
||||
browse: self.browse.clone(),
|
||||
filter: self.filter.clone(),
|
||||
sort: self.sort,
|
||||
selection: self.selection.clone(),
|
||||
};
|
||||
let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() };
|
||||
serde_json::to_vec_pretty(&view).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save the view state (source, filter, sort, selection) if it changed since it was last
|
||||
/// Save the view state (source, sort, selection) if it changed since it was last
|
||||
/// written. Native hosts get this from [`Session::close_library`]; the browser host calls it
|
||||
/// periodically, since a tab can be closed without notice.
|
||||
pub fn save_view(&mut self) {
|
||||
@@ -563,13 +563,14 @@ impl Session {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
}
|
||||
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
|
||||
/// trims it to the new size. Saved with the library preferences.
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default) in place: the cached thumbnails
|
||||
/// stay (the next write trims to the new size), and so do the textures shown from them.
|
||||
/// Saved with the library preferences.
|
||||
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
|
||||
self.cache_mb = mb;
|
||||
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
|
||||
let dir = lib.thumbs_dir();
|
||||
self.media.attach_disk_cache(&dir, self.cache_bytes());
|
||||
self.media.set_disk_cache_bytes(&dir, self.cache_bytes());
|
||||
}
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
+443
-58
@@ -16,7 +16,8 @@
|
||||
//! embedded JPEG for an unedited raw, else a thumbnail-level render — while the real render is
|
||||
//! prepared. Grid thumbnails of unedited raws likewise start from the embedded preview.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
@@ -25,12 +26,51 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const SETTINGS_HASH_ENTRIES: usize = 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SettingsHashes {
|
||||
entries: HashMap<PhotoId, (Weak<DevelopSettings>, u64)>,
|
||||
order: VecDeque<PhotoId>,
|
||||
#[cfg(test)]
|
||||
computations: usize,
|
||||
}
|
||||
|
||||
impl SettingsHashes {
|
||||
fn get(&mut self, id: PhotoId, settings: &Arc<DevelopSettings>) -> u64 {
|
||||
if let Some((old, hash)) = self.entries.get(&id)
|
||||
&& old.as_ptr() == Arc::as_ptr(settings)
|
||||
{
|
||||
return *hash;
|
||||
}
|
||||
let hash = settings.hash64();
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.computations += 1;
|
||||
}
|
||||
if !self.entries.contains_key(&id) {
|
||||
if self.entries.len() >= SETTINGS_HASH_ENTRIES
|
||||
&& let Some(oldest) = self.order.pop_front()
|
||||
{
|
||||
self.entries.remove(&oldest);
|
||||
}
|
||||
self.order.push_back(id);
|
||||
}
|
||||
self.entries.insert(id, (Arc::downgrade(settings), hash));
|
||||
hash
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 4;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 11;
|
||||
|
||||
/// 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];
|
||||
|
||||
pub fn thumb_bucket(long_edge: usize) -> usize {
|
||||
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
|
||||
}
|
||||
|
||||
/// Memory budgets.
|
||||
const THUMB_SOURCE_BYTES: usize = 384 << 20;
|
||||
const RENDERED_MEM_BYTES: usize = 128 << 20;
|
||||
@@ -71,9 +111,31 @@ pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), St
|
||||
/// `max_edge` (set by the app). `None`: no usable preview.
|
||||
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
||||
|
||||
/// Pixels and the source interpretation learned while decoding; they must be cached together.
|
||||
#[derive(Clone)]
|
||||
pub struct DecodedSource {
|
||||
pub image: Arc<Rgb32f>,
|
||||
pub info: Option<SourceInfo>,
|
||||
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
|
||||
/// catalog's header facts lack (used when `info` is `None`).
|
||||
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
|
||||
}
|
||||
|
||||
impl DecodedSource {
|
||||
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
|
||||
DecodedSource { image, info, camera_tone: None }
|
||||
}
|
||||
|
||||
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
|
||||
/// with any stored camera tone curve.
|
||||
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
|
||||
self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Arc<Rgb32f>),
|
||||
Loaded(Box<DecodedSource>),
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
@@ -82,6 +144,9 @@ pub enum SourceRef {
|
||||
path: String,
|
||||
max_edge: usize,
|
||||
loader: Option<FileLoader>,
|
||||
/// The photo's smart preview, used when the original can't be read (offline drive). The
|
||||
/// UI thread never checks whether the original is there; the render worker finds out.
|
||||
fallback: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// A smart preview standing in for a missing original.
|
||||
Smart {
|
||||
@@ -91,28 +156,42 @@ pub enum SourceRef {
|
||||
|
||||
impl SourceRef {
|
||||
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
|
||||
match self {
|
||||
SourceRef::Loaded(a) => Ok(a.clone()),
|
||||
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
|
||||
SourceRef::File { path, max_edge, loader } => match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
},
|
||||
self.load_source().map(|s| s.image)
|
||||
}
|
||||
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)),
|
||||
SourceRef::File { path, max_edge, loader, fallback } => {
|
||||
let r = match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
};
|
||||
// An offline original renders from its smart preview (no decoder facts: header ones).
|
||||
return match (r, fallback) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
(Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")),
|
||||
(r, _) => r,
|
||||
};
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SourceRef::Smart { path } => crate::smart::load(path),
|
||||
SourceRef::Smart { path } => return crate::smart::load(path),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
|
||||
}
|
||||
SourceRef::Smart { .. } => return Err("smart previews are not available here".into()),
|
||||
};
|
||||
Ok(DecodedSource::new(image, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MediaCache {
|
||||
settings_hashes: SettingsHashes,
|
||||
/// Decoded thumbnail-level sources (LRU by bytes).
|
||||
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
|
||||
thumbs: Lru<PhotoId, DecodedSource>,
|
||||
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
|
||||
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
|
||||
previews: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
|
||||
full: Option<(PhotoId, Arc<Rgb32f>, u64)>,
|
||||
full: Option<(PhotoId, DecodedSource, u64)>,
|
||||
/// How many previews to keep (LRU).
|
||||
pub preview_capacity: usize,
|
||||
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
|
||||
@@ -125,6 +204,9 @@ pub struct MediaCache {
|
||||
pub file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// The library's smart previews folder (originals offline: render from the proxy).
|
||||
pub smart_dir: Option<std::path::PathBuf>,
|
||||
/// Whether originals (and smart previews) are on disk: cached and checked off the UI thread
|
||||
/// in the app (see [`crate::availability`]).
|
||||
pub availability: crate::availability::Availability,
|
||||
scenes: Vec<lightcraft_scenes::Scene>,
|
||||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||||
pub rendered: Arc<PreviewCache>,
|
||||
@@ -134,6 +216,7 @@ impl Default for MediaCache {
|
||||
fn default() -> Self {
|
||||
let budget = crate::memory::cache_share(crate::memory::budget());
|
||||
MediaCache {
|
||||
settings_hashes: SettingsHashes::default(),
|
||||
thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)),
|
||||
previews: Vec::new(),
|
||||
full: None,
|
||||
@@ -144,6 +227,7 @@ impl Default for MediaCache {
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
smart_dir: None,
|
||||
availability: Default::default(),
|
||||
scenes: Vec::new(),
|
||||
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
|
||||
}
|
||||
@@ -156,17 +240,31 @@ fn rendered_budget(share: usize) -> usize {
|
||||
}
|
||||
|
||||
fn source_bytes(img: &Rgb32f) -> usize {
|
||||
img.data.len() * 12 + 64
|
||||
img.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>()
|
||||
}
|
||||
|
||||
impl MediaCache {
|
||||
/// Keep rendered thumbnails on disk in `dir` as well.
|
||||
/// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired
|
||||
/// (its files stay): render jobs still holding it can't write through it any more, even
|
||||
/// into a directory a later clear emptied (`dir` may be the same one).
|
||||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||||
self.rendered.retire();
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
|
||||
}
|
||||
|
||||
/// Change the disk budget of the cache in `dir`: in place when that cache is attached (its
|
||||
/// thumbnails are keyed by content, so they and the textures shown from them stay valid,
|
||||
/// and the same cache object keeps its generation), else attach it.
|
||||
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||||
match self.rendered.disk().filter(|d| d.dir() == dir) {
|
||||
Some(d) => d.set_budget(disk_bytes),
|
||||
None => self.attach_disk_cache(dir, disk_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
|
||||
pub fn clear_sources(&mut self) {
|
||||
self.settings_hashes = SettingsHashes::default();
|
||||
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
|
||||
self.previews.clear();
|
||||
self.full = None;
|
||||
@@ -207,8 +305,8 @@ impl MediaCache {
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
let freed = match which {
|
||||
0 => self.thumbs.pop_oldest(),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1)),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1.image)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1.image)),
|
||||
_ => self.rendered.evict_oldest(),
|
||||
};
|
||||
match freed {
|
||||
@@ -219,6 +317,10 @@ impl MediaCache {
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
|
||||
self.get_source(id, level).map(|s| s.image)
|
||||
}
|
||||
|
||||
fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option<DecodedSource> {
|
||||
match level {
|
||||
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
|
||||
SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| {
|
||||
@@ -232,11 +334,31 @@ impl MediaCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
|
||||
/// (with a smart preview's stored camera tone curve).
|
||||
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
|
||||
let cached = || {
|
||||
self.thumbs
|
||||
.peek(&id)
|
||||
.into_iter()
|
||||
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
|
||||
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
|
||||
};
|
||||
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
|
||||
Some(s) => s.info_or(header),
|
||||
None => header,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
|
||||
self.insert_source(id, level, DecodedSource::new(img, None));
|
||||
}
|
||||
|
||||
fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) {
|
||||
let tick = lightcraft_preview::next_tick();
|
||||
match level {
|
||||
SourceLevel::Thumb => {
|
||||
let cost = source_bytes(&img);
|
||||
let cost = source_bytes(&img.image);
|
||||
self.thumbs.insert(id, img, cost);
|
||||
}
|
||||
SourceLevel::Preview => {
|
||||
@@ -270,29 +392,36 @@ impl MediaCache {
|
||||
/// Decoded sources held: (thumbnail level, preview level, full size).
|
||||
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
|
||||
use crate::memory::Usage;
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1.image)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1.image))).unwrap_or_default();
|
||||
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
|
||||
}
|
||||
|
||||
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
|
||||
if let Some(a) = self.get(p.id, level) {
|
||||
return SourceRef::Loaded(a);
|
||||
if let Some(a) = self.get_source(p.id, level) {
|
||||
return SourceRef::Loaded(Box::new(a));
|
||||
}
|
||||
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
|
||||
let max_edge = level.max_edge().min(match (&p.source, level) {
|
||||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
_ => usize::MAX,
|
||||
});
|
||||
// the original is offline: its smart preview, when there is one
|
||||
// the original is offline: its smart preview, when there is one. Only cached answers are
|
||||
// used here (no file-system call on the UI thread in the app); an original not known to
|
||||
// be offline is tried first and its smart preview is the render worker's fallback.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
|
||||
&& !std::path::Path::new(path).exists()
|
||||
{
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
if crate::smart::is_valid(&sp) {
|
||||
// (a proxy cut short fails to load, like the offline original; the worker's fallback
|
||||
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
|
||||
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
|
||||
return SourceRef::Smart { path: sp };
|
||||
}
|
||||
let mut r = self.origin_ref(&p.source, max_edge);
|
||||
if let SourceRef::File { fallback, .. } = &mut r {
|
||||
*fallback = Some(sp);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
self.origin_ref(&p.source, max_edge)
|
||||
}
|
||||
@@ -307,10 +436,10 @@ impl MediaCache {
|
||||
}
|
||||
match self.scenes.iter().find(|s| s.id == *scene) {
|
||||
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None },
|
||||
}
|
||||
}
|
||||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() },
|
||||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +447,11 @@ impl MediaCache {
|
||||
/// Everything needed to render one photo, detached from the session.
|
||||
#[derive(Clone)]
|
||||
pub struct RenderJob {
|
||||
/// Assigned by a frontend to distinguish late completions, even for an identical render key.
|
||||
pub request_id: u64,
|
||||
pub cache_generation: u64,
|
||||
/// Content identity for validating a decoded source returned after a reload.
|
||||
pub source_key: Option<Hash128>,
|
||||
pub photo: PhotoId,
|
||||
pub level: SourceLevel,
|
||||
pub source: SourceRef,
|
||||
@@ -339,12 +473,14 @@ pub struct RenderJob {
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
pub request_id: u64,
|
||||
pub source_key: Option<Hash128>,
|
||||
pub photo: PhotoId,
|
||||
pub level: SourceLevel,
|
||||
pub key: u64,
|
||||
pub rendered: Result<Rendered, String>,
|
||||
/// A source that was loaded by this job (to be inserted into the cache).
|
||||
pub loaded: Option<Arc<Rgb32f>>,
|
||||
pub loaded: Option<DecodedSource>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
}
|
||||
@@ -396,11 +532,13 @@ impl RenderJob {
|
||||
|
||||
pub fn run(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get(*key)
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
@@ -410,30 +548,43 @@ impl RenderJob {
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
match self.source.load() {
|
||||
Ok(src) => {
|
||||
match self.source.load_source() {
|
||||
Ok(source) => {
|
||||
let src = &source.image;
|
||||
let info = source.info_or(self.info);
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let rendered = develop(&src, &self.info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put(*key, Arc::new(rendered.image.clone()));
|
||||
cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
if let Some((cache, key)) = &self.view_cache
|
||||
&& self.request.quality == Quality::Full
|
||||
{
|
||||
cache.put_deferred(*key, Arc::new(rendered.image.clone()));
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(rendered),
|
||||
loaded: (!was_loaded).then_some(src),
|
||||
loaded: (!was_loaded).then_some(source),
|
||||
quick: None,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None },
|
||||
Err(e) => RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,6 +624,7 @@ pub enum QuickSource {
|
||||
/// renders, the embedded preview of an unedited raw, a thumbnail-level render.
|
||||
#[derive(Clone)]
|
||||
pub struct QuickJob {
|
||||
pub request_id: u64,
|
||||
pub photo: PhotoId,
|
||||
pub key: u64,
|
||||
/// Cached renders to try first, in order: (cache, key).
|
||||
@@ -486,7 +638,17 @@ pub struct QuickJob {
|
||||
impl QuickJob {
|
||||
pub fn run(self) -> RenderResult {
|
||||
let (photo, key) = (self.photo, self.key);
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
|
||||
let request_id = self.request_id;
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
|
||||
request_id,
|
||||
source_key: None,
|
||||
photo,
|
||||
level: SourceLevel::Thumb,
|
||||
key,
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick,
|
||||
};
|
||||
let rendered = |image: Rgba8| {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
@@ -503,7 +665,7 @@ impl QuickJob {
|
||||
}
|
||||
if let Some(job) = self.small {
|
||||
let r = job.run();
|
||||
return RenderResult { key, quick: Some(QuickSource::Small), ..r };
|
||||
return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r };
|
||||
}
|
||||
done(Err("no quick preview".into()), None)
|
||||
}
|
||||
@@ -521,11 +683,16 @@ pub fn content_key(p: &Photo) -> String {
|
||||
pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||||
return SourceInfo { raw: true, ..Default::default() };
|
||||
}
|
||||
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
|
||||
// balance and the display tone curve, like any other rendered file.
|
||||
if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
|
||||
if p.develops_raw() {
|
||||
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
@@ -537,7 +704,7 @@ impl crate::Session {
|
||||
/// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`]
|
||||
/// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk).
|
||||
pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option<RenderJob> {
|
||||
let b = THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(THUMB_SIZES[THUMB_SIZES.len() - 1]);
|
||||
let b = thumb_bucket(long_edge);
|
||||
self.build_job(id, b, b, false, true, Some(b))
|
||||
}
|
||||
|
||||
@@ -558,8 +725,12 @@ impl crate::Session {
|
||||
// the photo id is part of the key: two photos with the same settings and size must not
|
||||
// share a result (a view slot showing photo A would otherwise look current for photo B)
|
||||
// …and so is the file's content: a file changed on disk (Reload) renders afresh
|
||||
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||||
let key = settings.hash64()
|
||||
let content_key = content_key(&p);
|
||||
let source_key = Hasher128::new().str(&content_key).finish();
|
||||
let content = source_key.0 as u64;
|
||||
// Temporary "before" settings must not replace the photo's cached develop hash.
|
||||
let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) };
|
||||
let key = settings_hash
|
||||
^ ((max_w as u64) << 40)
|
||||
^ ((max_h as u64) << 20)
|
||||
^ (apply_crop as u64)
|
||||
@@ -567,10 +738,19 @@ impl crate::Session {
|
||||
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15)
|
||||
^ content.rotate_left(17);
|
||||
let cache = thumb_bucket.map(|b| {
|
||||
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
|
||||
let k = Hasher128::new()
|
||||
.str(&content_key)
|
||||
.u64(settings_hash)
|
||||
.u64(b as u64)
|
||||
.u64(RENDER_CACHE_VERSION)
|
||||
.u64(crate::camera_profiles::cache_key())
|
||||
.finish();
|
||||
(self.media.rendered.clone(), k)
|
||||
});
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(source_key),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -596,7 +776,14 @@ impl crate::Session {
|
||||
|
||||
/// Cache key of a variant thumbnail ([`Self::variant_job`]).
|
||||
pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 {
|
||||
Hasher128::new().str(&content_key(p)).str("variant").u64(settings.hash64()).u64(edge as u64).u64(RENDER_CACHE_VERSION).finish()
|
||||
Hasher128::new()
|
||||
.str(&content_key(p))
|
||||
.str("variant")
|
||||
.u64(settings.hash64())
|
||||
.u64(edge as u64)
|
||||
.u64(RENDER_CACHE_VERSION)
|
||||
.u64(crate::camera_profiles::cache_key())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset
|
||||
@@ -611,6 +798,9 @@ impl crate::Session {
|
||||
let source = self.media.source_ref(&p, level);
|
||||
let ck = Self::variant_key(&p, settings, edge);
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(Hasher128::new().str(&content_key(&p)).finish()),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -625,9 +815,61 @@ impl crate::Session {
|
||||
})
|
||||
}
|
||||
|
||||
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
|
||||
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
|
||||
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
|
||||
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
|
||||
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
|
||||
let p = self.catalog.photo(id)?.clone();
|
||||
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
|
||||
// user's Rotate Left/Right, so carry the box (and the photo's size) over to it.
|
||||
let orient = p.develop.orientation;
|
||||
let face = orient.map_norm_rect(face);
|
||||
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
|
||||
let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7;
|
||||
// finite, within the photo, and never wider than its short side (so the clamps below are valid)
|
||||
let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) };
|
||||
let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h);
|
||||
let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 });
|
||||
let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0);
|
||||
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
|
||||
let mut settings = (*p.develop).clone();
|
||||
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
|
||||
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
|
||||
// pixels along the long edge that leave `edge` across the crop
|
||||
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
|
||||
let level = SourceLevel::for_size(needed as usize);
|
||||
let source = self.media.source_ref(&p, level);
|
||||
let ck = Self::variant_key(&p, &settings, edge);
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(Hasher128::new().str(&content_key(&p)).finish()),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
origin: p.source.clone(),
|
||||
info: source_info(&p),
|
||||
settings: Arc::new(settings),
|
||||
request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) },
|
||||
key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64),
|
||||
cache: Some((self.media.rendered.clone(), ck)),
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
|
||||
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||||
Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish()
|
||||
Hasher128::new()
|
||||
.str(&content_key(p))
|
||||
.str("view")
|
||||
.u64(p.develop.hash64())
|
||||
.u64(apply_crop as u64)
|
||||
.u64(RENDER_CACHE_VERSION)
|
||||
.u64(crate::camera_profiles::cache_key())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the
|
||||
@@ -657,7 +899,7 @@ impl crate::Session {
|
||||
let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish();
|
||||
let key = h.0 as u64;
|
||||
let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge())));
|
||||
Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
|
||||
Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
|
||||
}
|
||||
|
||||
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
|
||||
@@ -669,6 +911,7 @@ impl crate::Session {
|
||||
let (path, l) = self.embedded_of(&p)?;
|
||||
let edge = job.request.max_w.max(job.request.max_h);
|
||||
Some(QuickJob {
|
||||
request_id: 0,
|
||||
photo: job.photo,
|
||||
key: job.key,
|
||||
cached: job.cache.clone().into_iter().collect(),
|
||||
@@ -679,8 +922,10 @@ impl crate::Session {
|
||||
|
||||
/// Accept a finished job's loaded source into the cache.
|
||||
pub fn accept(&mut self, r: &RenderResult) {
|
||||
if let Some(src) = &r.loaded {
|
||||
self.media.insert(r.photo, r.level, src.clone());
|
||||
if let Some(src) = &r.loaded
|
||||
&& r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key))
|
||||
{
|
||||
self.media.insert_source(r.photo, r.level, src.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,12 +968,19 @@ impl crate::Session {
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Prefer decoder facts to header-only metadata for pixel-statistics commands.
|
||||
pub fn source_info(&self, id: PhotoId) -> SourceInfo {
|
||||
let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default();
|
||||
self.media.source_facts(id, header)
|
||||
}
|
||||
|
||||
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
|
||||
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let r = self.media.source_ref(&p, level).load()?;
|
||||
self.media.insert(id, level, r.clone());
|
||||
Ok(r)
|
||||
let r = self.media.source_ref(&p, level).load_source()?;
|
||||
let image = r.image.clone();
|
||||
self.media.insert_source(id, level, r);
|
||||
Ok(image)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,7 +995,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>,
|
||||
@@ -756,10 +1010,141 @@ pub struct ProbeInfo {
|
||||
|
||||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumbnail_hash_tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::Op;
|
||||
|
||||
fn session() -> crate::Session {
|
||||
let mut s = crate::Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s
|
||||
}
|
||||
#[test]
|
||||
fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() {
|
||||
let mut s = session();
|
||||
for edge in [256, 384, 512, 256] {
|
||||
let p = s.catalog.photo(PhotoId(1)).unwrap().clone();
|
||||
let b = thumb_bucket(edge);
|
||||
// Independent original formula: never asks the new hash cache for expected values.
|
||||
let settings = p.develop.hash64();
|
||||
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||||
let expected =
|
||||
settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17);
|
||||
let disk = Hasher128::new()
|
||||
.str(&content_key(&p))
|
||||
.u64(settings)
|
||||
.u64(b as u64)
|
||||
.u64(RENDER_CACHE_VERSION)
|
||||
.u64(crate::camera_profiles::cache_key())
|
||||
.finish();
|
||||
let job = s.thumb_job(p.id, edge).unwrap();
|
||||
assert_eq!(job.key, expected);
|
||||
assert_eq!(job.cache.unwrap().1, disk);
|
||||
}
|
||||
s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
s.render_job(PhotoId(1), 256, 256, true, true).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
assert_eq!(s.media.settings_hashes.computations, 1);
|
||||
let mut edit = DevelopSettings::default();
|
||||
edit.light.exposure = 1.0;
|
||||
let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
s.thumb_job(PhotoId(1), 384).unwrap();
|
||||
assert_eq!(s.media.settings_hashes.computations, 2);
|
||||
s.catalog.apply(undo).unwrap();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
assert_eq!(s.media.settings_hashes.computations, 3);
|
||||
}
|
||||
#[test]
|
||||
fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() {
|
||||
let mut hashes = SettingsHashes::default();
|
||||
let shared = Arc::new(DevelopSettings::default());
|
||||
for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 {
|
||||
assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64());
|
||||
}
|
||||
assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES);
|
||||
assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES);
|
||||
assert_eq!(Arc::strong_count(&shared), 1);
|
||||
let mut edit = (*shared).clone();
|
||||
edit.light.exposure = 1.0;
|
||||
let edit = Arc::new(edit);
|
||||
let a = PhotoId(10000);
|
||||
let b = PhotoId(10001);
|
||||
let initial = hashes.get(a, &shared);
|
||||
assert_eq!(hashes.get(b, &shared), initial);
|
||||
assert_ne!(hashes.get(a, &edit), initial);
|
||||
assert_eq!(hashes.get(b, &shared), initial);
|
||||
let mut s = session();
|
||||
s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
s.media.clear_sources();
|
||||
assert!(s.media.settings_hashes.entries.is_empty());
|
||||
assert!(s.media.settings_hashes.order.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn source_from_before_content_reload_is_not_accepted() {
|
||||
let mut s = session();
|
||||
s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default()))));
|
||||
let old = s.thumb_job(PhotoId(1), 256).unwrap().run();
|
||||
assert!(old.loaded.is_some());
|
||||
s.catalog
|
||||
.apply(Op::SetContent {
|
||||
id: PhotoId(1),
|
||||
width: 6000,
|
||||
height: 4000,
|
||||
file_size: 99,
|
||||
content_hash: Some("replacement".into()),
|
||||
preview_only: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.media.forget(PhotoId(1));
|
||||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decoder_info_survives_render_jobs_cache_and_eviction() {
|
||||
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.01 * (i + 1) as f32;
|
||||
[x, (x * 2.0).min(0.9)]
|
||||
}))
|
||||
.unwrap();
|
||||
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
|
||||
job.source = SourceRef::File {
|
||||
path: "synthetic.arw".into(),
|
||||
max_edge: 64,
|
||||
loader: Some(Arc::new(move |_, _| {
|
||||
let mut image = Rgb32f::new(64, 64);
|
||||
image.data.fill([0.1; 3]);
|
||||
Ok((image, info))
|
||||
})),
|
||||
fallback: None,
|
||||
};
|
||||
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
|
||||
job.settings = Arc::new(DevelopSettings::default());
|
||||
let r = job.clone().run();
|
||||
assert_eq!(r.loaded.as_ref().unwrap().info, Some(info));
|
||||
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
|
||||
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
|
||||
s.accept(&r);
|
||||
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
|
||||
let again = job.run();
|
||||
assert!(again.loaded.is_none());
|
||||
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
|
||||
s.media.forget(id);
|
||||
assert!(s.media.get_source(id, r.level).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_evicts_least_recently_used_across_caches() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
|
||||
@@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source};
|
||||
pub struct OriginalGuard {
|
||||
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
|
||||
by_name: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
|
||||
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
|
||||
}
|
||||
|
||||
impl OriginalGuard {
|
||||
@@ -32,6 +34,7 @@ impl OriginalGuard {
|
||||
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
|
||||
pub fn add(&mut self, path: &Path, label: &str) {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
|
||||
self.links = std::sync::OnceLock::new();
|
||||
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
|
||||
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
@@ -58,6 +61,19 @@ impl OriginalGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
// An imported symlink can have a different name from its original. A write to
|
||||
// that original would change what the library reads through the link. Only do
|
||||
// this fallback for existing targets that the name index did not protect; the links are
|
||||
// looked up once per guard, not once per target.
|
||||
let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect());
|
||||
for (p, what) in links {
|
||||
if same_file(p, target) {
|
||||
return Err(format!(
|
||||
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -68,9 +84,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+160
-80
@@ -395,7 +395,12 @@ impl MoveFs for RealFs {
|
||||
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
|
||||
/// volume): the rename must go through a temporary name.
|
||||
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
|
||||
from != to && from.to_string_lossy().to_lowercase() == to.to_string_lossy().to_lowercase() && fs.exists(to) && fs.same_file(from, to)
|
||||
from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to)
|
||||
}
|
||||
|
||||
/// Windows accepts both separators; spelling differences must not hide file identity.
|
||||
fn folded_path(path: &str) -> String {
|
||||
if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() }
|
||||
}
|
||||
|
||||
/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a
|
||||
@@ -439,7 +444,7 @@ pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
|
||||
/// [`move_file`] on the file system `fs`.
|
||||
pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
if from == to {
|
||||
if Path::new(from) == Path::new(to) {
|
||||
return Ok(());
|
||||
}
|
||||
let fail = |message: String| MoveError { message, moved: false };
|
||||
@@ -538,6 +543,92 @@ fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
|
||||
/// rename once. No file-system calls.
|
||||
pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec<std::sync::Arc<Photo>> {
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
ids.iter()
|
||||
.filter_map(|id| cat.photo(*id))
|
||||
.filter(|p| match &p.source {
|
||||
Source::File { path } => seen_paths.insert(path.clone()),
|
||||
_ => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check
|
||||
/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow
|
||||
/// drive). Whether a case-only change names this very file is decided on the disk.
|
||||
pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
|
||||
plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b))
|
||||
}
|
||||
|
||||
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
|
||||
/// two spellings name one file (see [`MoveFs`]).
|
||||
fn plan_rename_core(
|
||||
photos: &[std::sync::Arc<Photo>],
|
||||
template: &str,
|
||||
start: usize,
|
||||
exists: &dyn Fn(&Path) -> bool,
|
||||
same_file: &dyn Fn(&Path, &Path) -> bool,
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
for (seq, p) in (start..).zip(photos) {
|
||||
let want = expand(template, p, seq);
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
let candidate = |k: usize| {
|
||||
if k == 0 {
|
||||
want.clone()
|
||||
} else if ext.is_empty() {
|
||||
format!("{stem}-{k}")
|
||||
} else {
|
||||
format!("{stem}-{k}.{ext}")
|
||||
}
|
||||
};
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = folded_path(&tp);
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let (from, to) = (Path::new(path), Path::new(&tp));
|
||||
let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
if !taken.contains(&key) && (same || !exists(to)) {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let from_path = match &p.source {
|
||||
Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
|
||||
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
@@ -546,70 +637,7 @@ impl Session {
|
||||
|
||||
/// [`Session::plan_rename`] on the file system `fs`.
|
||||
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
// photos sharing one file (virtual copies) rename once
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
let mut seq = start;
|
||||
for id in ids {
|
||||
let Some(p) = self.catalog.photo(*id) else { continue };
|
||||
if let Source::File { path } = &p.source
|
||||
&& !seen_paths.insert(path.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let want = expand(template, p, seq);
|
||||
seq += 1;
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
let candidate = |k: usize| {
|
||||
if k == 0 {
|
||||
want.clone()
|
||||
} else if ext.is_empty() {
|
||||
format!("{stem}-{k}")
|
||||
} else {
|
||||
format!("{stem}-{k}.{ext}")
|
||||
}
|
||||
};
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let same = tp == *path || is_respelling(fs, Path::new(path), Path::new(&tp));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
let on_disk = fs.exists(Path::new(&tp)) && !same;
|
||||
if !taken.contains(&key) && !on_disk {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let from_path = match &p.source {
|
||||
Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
out.push(RenamePlan { id: id.0, from: p.file_name.clone(), to, from_path, to_path });
|
||||
}
|
||||
out
|
||||
plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b))
|
||||
}
|
||||
|
||||
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
|
||||
@@ -633,6 +661,7 @@ impl Session {
|
||||
};
|
||||
let Some(plan) = plan else { continue };
|
||||
let source = match &plan.to_path {
|
||||
Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(),
|
||||
Some(t) => Source::File { path: t.clone() },
|
||||
None => p.source.clone(),
|
||||
};
|
||||
@@ -645,7 +674,7 @@ impl Session {
|
||||
let moves: Vec<(String, String)> = plans
|
||||
.iter()
|
||||
.filter_map(|pl| match (&pl.from_path, &pl.to_path) {
|
||||
(Some(a), Some(b)) if a != b => Some((a.clone(), b.clone())),
|
||||
(Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -709,7 +738,7 @@ impl Session {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| rec(s, o, v)),
|
||||
Op::SetFile { id, source: Source::File { path: to }, .. } => {
|
||||
if let Some(Source::File { path: from }) = s.catalog.photo(*id).map(|p| &p.source)
|
||||
&& from != to
|
||||
&& Path::new(from) != Path::new(to)
|
||||
&& !v.iter().any(|(f, _)| f == from)
|
||||
{
|
||||
v.push((from.clone(), to.clone()));
|
||||
@@ -745,13 +774,17 @@ mod tests {
|
||||
fail: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
|
||||
fn norm(p: &Path) -> String {
|
||||
p.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = p.to_string_lossy();
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p })
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p })
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
pub fn listing(&self) -> Vec<(String, String)> {
|
||||
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.clone(), String::from_utf8_lossy(c).to_string())).collect();
|
||||
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
@@ -768,14 +801,14 @@ mod tests {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| a.to_string_lossy().contains(f.as_str())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut()[i].0 = b.to_string_lossy().to_string();
|
||||
self.files.borrow_mut()[i].0 = Self::norm(b);
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
@@ -784,7 +817,7 @@ mod tests {
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
let c = self.files.borrow()[i].1.clone();
|
||||
self.files.borrow_mut().push((b.to_string_lossy().to_string(), c));
|
||||
self.files.borrow_mut().push((Self::norm(b), c));
|
||||
Ok(())
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
@@ -793,17 +826,64 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = sidecar.with_extension("").to_string_lossy().to_string();
|
||||
let stem = Self::norm(&sidecar.with_extension(""));
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
let p = Path::new(f);
|
||||
p.with_extension("").to_string_lossy() == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_photo(id: u64, path: &str) -> Photo {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
// the host's separator, so `dir.join(name)` in the planner spells the same path the same way
|
||||
let path = path.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() {
|
||||
let fs = FakeFs::new(true, &[("N:/photos/IMG.jpg", "image"), ("N:/photos/IMG.xmp", "edits"), ("N:/photos/other.jpg", "other")]);
|
||||
// Any attempt to move the unchanged photo (including rollback) must fail.
|
||||
fs.fail.borrow_mut().push("IMG".into());
|
||||
let mut s = Session::new();
|
||||
// spelled with `/` on purpose (not `file_photo`, which uses the host's separator)
|
||||
for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
}
|
||||
let before = fs.listing();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1);
|
||||
assert_ne!(plans[0].from_path, plans[0].to_path, "fixture exercises different separator spelling");
|
||||
assert_eq!(s.apply_rename_with(&fs, &plans).unwrap(), 0);
|
||||
assert_eq!(fs.listing(), before);
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "N:/photos/IMG.jpg".into() });
|
||||
assert!(s.undo.is_empty());
|
||||
move_file_with(&fs, "N:/photos/IMG.jpg", "N:/photos\\IMG.jpg").unwrap();
|
||||
let separator_only = Op::SetFile { id: PhotoId(1), file_name: "IMG.jpg".into(), source: Source::File { path: "N:/photos\\IMG.jpg".into() } };
|
||||
assert!(s.file_moves(&separator_only).is_empty());
|
||||
|
||||
// A batch may combine that no-op with a genuine rename, without touching IMG or its sidecar.
|
||||
let mut batch = plans;
|
||||
batch.extend(s.plan_rename_with(&fs, &[PhotoId(2)], "renamed", 1));
|
||||
assert_eq!(s.apply_rename_with(&fs, &batch).unwrap(), 1);
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("N:/photos/IMG.jpg".into(), "image".into()),
|
||||
("N:/photos/IMG.xmp".into(), "edits".into()),
|
||||
("N:/photos/renamed.jpg".into(), "other".into())
|
||||
]
|
||||
);
|
||||
let undo = &s.undo.last().unwrap().op;
|
||||
let moves = s.file_moves(undo);
|
||||
assert_eq!(moves.len(), 1);
|
||||
assert_eq!(Path::new(&moves[0].1), Path::new("N:/photos/other.jpg"));
|
||||
move_all(&fs, &moves).unwrap();
|
||||
s.catalog.apply(undo.clone()).unwrap();
|
||||
assert_eq!(fs.listing(), before);
|
||||
}
|
||||
|
||||
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
|
||||
@@ -861,7 +941,7 @@ mod tests {
|
||||
let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1);
|
||||
// c can't be renamed (the share went away), and neither can Trip-1 be moved back
|
||||
fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]);
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string();
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/");
|
||||
assert!(err.contains("rename /p/c.jpg"), "{err}");
|
||||
assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}");
|
||||
assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}");
|
||||
@@ -876,7 +956,7 @@ mod tests {
|
||||
);
|
||||
// the catalog matches the disk
|
||||
let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::File { path } => path.replace('\\', "/"),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
|
||||
@@ -890,7 +970,7 @@ mod tests {
|
||||
fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]);
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(e.moved, "{e}");
|
||||
assert!(e.message.contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert!(e.message.replace('\\', "/").contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]);
|
||||
// … and when the file does go back, its sidecars follow it
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The model download as a background task the session starts, watches and cancels without
|
||||
//! ever blocking: progress lives in atomics, messages behind a mutex only ever `try_lock`ed
|
||||
//! by the session (the download thread holds it for a few instructions at a time).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_segment::fetch::{self, Options, Progress};
|
||||
|
||||
/// What the session sees of a download.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Status {
|
||||
pub running: bool,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
/// The file being fetched.
|
||||
pub file: String,
|
||||
/// The last download's error (cleared when a new one starts).
|
||||
pub error: Option<String>,
|
||||
/// The last download finished and the files are in place.
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Text {
|
||||
file: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
running: AtomicBool,
|
||||
cancel: AtomicBool,
|
||||
finished: AtomicBool,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
text: Mutex<Text>,
|
||||
}
|
||||
|
||||
/// Clears `running` when the download thread ends, however it ends.
|
||||
struct Running(Arc<Shared>);
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Downloader {
|
||||
shared: Arc<Shared>,
|
||||
/// The last text seen (when the download thread held the lock at that moment).
|
||||
last: Mutex<Text>,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<Text>) -> std::sync::MutexGuard<'_, Text> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn status(&self) -> Status {
|
||||
let s = &self.shared;
|
||||
if let Ok(t) = s.text.try_lock()
|
||||
&& let Ok(mut last) = self.last.try_lock()
|
||||
{
|
||||
last.file.clone_from(&t.file);
|
||||
last.error.clone_from(&t.error);
|
||||
}
|
||||
// (only the session's thread locks `last`)
|
||||
let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
|
||||
Status {
|
||||
running: s.running.load(Ordering::SeqCst),
|
||||
done: s.done.load(Ordering::SeqCst),
|
||||
total: s.total.load(Ordering::SeqCst),
|
||||
file,
|
||||
error,
|
||||
finished: s.finished.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn running(&self) -> bool {
|
||||
self.shared.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Ask a running download to stop (it keeps its partial files to resume later).
|
||||
pub fn cancel(&self) -> bool {
|
||||
let running = self.running();
|
||||
if running {
|
||||
self.shared.cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
running
|
||||
}
|
||||
|
||||
/// Start downloading `files` from `mirrors` into `dir` on a background thread. False when
|
||||
/// one is already running.
|
||||
pub fn start(&self, files: &'static [fetch::FileSpec], mirrors: Vec<String>, dir: PathBuf, opts: Options) -> Result<bool, String> {
|
||||
let s = self.shared.clone();
|
||||
if s.running.swap(true, Ordering::SeqCst) {
|
||||
return Ok(false);
|
||||
}
|
||||
s.cancel.store(false, Ordering::SeqCst);
|
||||
s.finished.store(false, Ordering::SeqCst);
|
||||
s.done.store(0, Ordering::SeqCst);
|
||||
s.total.store(files.iter().filter_map(|f| f.size).sum(), Ordering::SeqCst);
|
||||
// nothing else runs while `running` was false
|
||||
*lock(&s.text) = Text::default();
|
||||
*lock(&self.last) = Text::default();
|
||||
let guard = Running(s.clone());
|
||||
let spawned = std::thread::Builder::new().name("sam3-download".into()).spawn(move || {
|
||||
let s = guard.0.clone();
|
||||
let mut last_file = String::new();
|
||||
let mut on_progress = |p: &Progress| {
|
||||
s.done.store(p.done, Ordering::SeqCst);
|
||||
s.total.store(p.total, Ordering::SeqCst);
|
||||
if p.file != last_file {
|
||||
last_file.clone_from(&p.file);
|
||||
lock(&s.text).file.clone_from(&p.file);
|
||||
}
|
||||
};
|
||||
let r =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
|
||||
let error = match r {
|
||||
Ok(Ok(())) => {
|
||||
s.finished.store(true, Ordering::SeqCst);
|
||||
log::info!("SAM 3 model downloaded to {}", dir.display());
|
||||
None
|
||||
}
|
||||
Ok(Err(e)) => Some(e.to_string()),
|
||||
Err(_) => Some("the download failed unexpectedly".to_string()),
|
||||
};
|
||||
if let Some(e) = &error {
|
||||
log::warn!("SAM 3 download: {e}");
|
||||
}
|
||||
lock(&s.text).error = error;
|
||||
drop(guard);
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
// the closure (and its guard) was dropped: `running` is false again
|
||||
Err(format!("could not start the download: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
//! AI masks: Object (clicks) and Describe (text) selections computed with SAM 3
|
||||
//! (`lightcraft-segment`, cargo feature `sam`; the desktop app enables it).
|
||||
//!
|
||||
//! **Nothing requires the model.** It is not part of LightCraft (SAM License); the user
|
||||
//! downloads it when they first use an AI mask and agree to (`segment.model.download`), or
|
||||
//! puts the files in the model folder themselves. Without it, AI mask requests fail with a
|
||||
//! clear "not installed" error; renders, exports and every other feature never touch it
|
||||
//! (stored segmentations are plain data in the develop settings).
|
||||
//!
|
||||
//! The model sees the photo as developed, uncropped and without masks, at 1008 px — the frame
|
||||
//! mask coordinates live in — so a stored segmentation lines up with the photo at any size.
|
||||
//! Everything that touches the model (loading it, rendering and encoding the photo, clicks,
|
||||
//! descriptions, detail passes) runs on one worker thread ([`worker`]); the session talks to it
|
||||
//! over channels and never waits on a lock. With [`Segmenter::background`] (the desktop app)
|
||||
//! commands return at once and [`Session::segment_poll`] applies the results; without it (CLI,
|
||||
//! MCP, tests) they wait for the worker.
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
mod download;
|
||||
#[cfg(feature = "sam")]
|
||||
mod worker;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{MaskShape, SegMask};
|
||||
use lightcraft_geom::Point;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Long edge of the image the model sees.
|
||||
pub const INPUT_EDGE: usize = 1008;
|
||||
/// Download size of the model (`model.safetensors`; the tokenizer files add ~2 MB).
|
||||
pub const MODEL_BYTES: u64 = 3_439_938_512;
|
||||
/// The model's licence (not LightCraft's): shown before downloading.
|
||||
pub const LICENSE_NAME: &str = "SAM License (Meta)";
|
||||
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
|
||||
/// How errors about a missing model start (the UI offers the download on it).
|
||||
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
|
||||
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
/// Most clicks one Object selection takes (the model's prompt grows with each).
|
||||
pub const MAX_CLICKS: usize = 64;
|
||||
|
||||
/// One click of an Object selection (normalized image coordinates).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Click {
|
||||
pub at: Point,
|
||||
pub include: bool,
|
||||
}
|
||||
|
||||
/// What a request's result is for (how [`Session::segment_poll`] applies it).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) enum Tag {
|
||||
/// A command waiting for the result itself.
|
||||
Wait,
|
||||
/// Load the model and encode the photo ahead of the first click.
|
||||
Prepare,
|
||||
/// Object clicks on component `comp` of mask `mask`.
|
||||
Clicks { photo: PhotoId, mask: u32, comp: usize, hint: Vec<Point>, exclude: Vec<Point> },
|
||||
/// A Describe selection: a new mask (`mask: None`) or a component combined by `op`.
|
||||
NewPrompt { photo: PhotoId, mask: Option<u32>, op: String, name: Option<String>, text: String },
|
||||
/// A zoomed-in pass for a component, computed from `shape` (without its detail).
|
||||
Detail { photo: PhotoId, mask: u32, comp: usize, shape: MaskShape },
|
||||
}
|
||||
|
||||
/// The model input: 8-bit RGB.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) struct Input {
|
||||
rgb: Vec<u8>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
/// What a detail pass re-runs.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) enum Prompt {
|
||||
Clicks(Vec<Click>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Object clicks sent to the worker and not applied yet (background mode): further clicks add
|
||||
/// to these, and the loupe shows them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PendingClicks {
|
||||
pub photo: PhotoId,
|
||||
pub mask: u32,
|
||||
pub comp: usize,
|
||||
pub hint: Vec<Point>,
|
||||
pub exclude: Vec<Point>,
|
||||
}
|
||||
|
||||
/// What [`Session::segment_poll`] did.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Polled {
|
||||
/// A mask changed.
|
||||
pub changed: bool,
|
||||
/// Errors and notices from background work, for the user.
|
||||
pub messages: Vec<String>,
|
||||
/// A mask that got a new selection (worth a detail pass).
|
||||
pub refine: Option<u32>,
|
||||
}
|
||||
|
||||
/// The model download (`segment.model.*`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct DownloadStatus {
|
||||
pub running: bool,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub file: String,
|
||||
pub error: Option<String>,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
/// The model (on its worker thread), the download, and AI mask requests in flight.
|
||||
pub struct Segmenter {
|
||||
/// Where the `facebook/sam3` files live (set by the app; `None`: no AI masks).
|
||||
pub dir: Option<PathBuf>,
|
||||
/// The user's list of download mirrors (one base URL per line), if any.
|
||||
pub mirrors_file: Option<PathBuf>,
|
||||
/// Run requests in the background and apply them in [`Session::segment_poll`] (the
|
||||
/// desktop app); otherwise commands wait for their result (CLI, MCP, tests).
|
||||
pub background: bool,
|
||||
pending: Option<PendingClicks>,
|
||||
messages: Vec<String>,
|
||||
#[cfg(feature = "sam")]
|
||||
worker: worker::Worker,
|
||||
#[cfg(feature = "sam")]
|
||||
download: download::Downloader,
|
||||
#[cfg(feature = "sam")]
|
||||
results: (std::sync::mpsc::Sender<worker::Outcome>, std::sync::mpsc::Receiver<worker::Outcome>),
|
||||
}
|
||||
|
||||
// (derivable only without the `sam` feature: the results channel has no Default)
|
||||
#[allow(clippy::derivable_impls)]
|
||||
impl Default for Segmenter {
|
||||
fn default() -> Self {
|
||||
Segmenter {
|
||||
dir: None,
|
||||
mirrors_file: None,
|
||||
background: false,
|
||||
pending: None,
|
||||
messages: Vec::new(),
|
||||
#[cfg(feature = "sam")]
|
||||
worker: worker::Worker::default(),
|
||||
#[cfg(feature = "sam")]
|
||||
download: download::Downloader::default(),
|
||||
#[cfg(feature = "sam")]
|
||||
results: std::sync::mpsc::channel(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Segmenter {
|
||||
/// Whether this build can compute AI masks at all.
|
||||
pub const AVAILABLE: bool = cfg!(feature = "sam");
|
||||
|
||||
/// Whether the model's files are in place.
|
||||
pub fn installed(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
if let Some(dir) = &self.dir {
|
||||
return lightcraft_segment::is_model_dir(dir);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether requests are queued or running (loading, analyzing the photo, a click…).
|
||||
pub fn busy(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.pending() > 0;
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the slow part runs now: loading the model or analyzing a photo.
|
||||
pub fn analyzing(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.encoding();
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether a zoomed-in detail pass is queued or running.
|
||||
pub fn detail_busy(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.detail() > 0;
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the model is in memory (it is unloaded after a while without use).
|
||||
pub fn loaded(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.worker.loaded();
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
|
||||
/// Clicks waiting for their segmentation.
|
||||
pub fn pending_clicks(&self) -> Option<&PendingClicks> {
|
||||
self.pending.as_ref()
|
||||
}
|
||||
|
||||
/// The model download's state.
|
||||
pub fn download_status(&self) -> DownloadStatus {
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let s = self.download.status();
|
||||
DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
DownloadStatus::default()
|
||||
}
|
||||
|
||||
/// Where the model is downloaded from, in order (the user's mirrors first).
|
||||
pub fn mirrors(&self) -> Vec<String> {
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let env = std::env::var(lightcraft_segment::fetch::MIRRORS_ENV).ok();
|
||||
lightcraft_segment::fetch::mirrors(env.as_deref(), self.mirrors_file.as_deref())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The model folder, if the model is there; otherwise an error that says what to do.
|
||||
pub fn model_dir(&self) -> Result<PathBuf, String> {
|
||||
if !Self::AVAILABLE {
|
||||
return Err("AI masks are not available in this build".into());
|
||||
}
|
||||
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
|
||||
if self.installed() {
|
||||
return Ok(dir);
|
||||
}
|
||||
let d = self.download_status();
|
||||
if d.running {
|
||||
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
|
||||
return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
|
||||
}
|
||||
Err(format!(
|
||||
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
|
||||
MODEL_BYTES as f64 / 1e9,
|
||||
dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Start downloading the model (on a background thread; see `segment.model.download`).
|
||||
/// `Ok(false)` when it is installed or downloading already.
|
||||
pub fn start_download(&self) -> Result<bool, String> {
|
||||
if !Self::AVAILABLE {
|
||||
return Err("AI masks are not available in this build".into());
|
||||
}
|
||||
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
|
||||
if self.installed() {
|
||||
return Ok(false);
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let mirrors = self.mirrors();
|
||||
if mirrors.is_empty() {
|
||||
return Err(lightcraft_segment::fetch::no_mirrors_message());
|
||||
}
|
||||
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::Options::default())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = dir;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a running download (its partial files stay, to resume). False when none runs.
|
||||
pub fn cancel_download(&self) -> bool {
|
||||
#[cfg(feature = "sam")]
|
||||
return self.download.cancel();
|
||||
#[cfg(not(feature = "sam"))]
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix `v` into a 64-bit hash.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn mix(h: u64, v: u64) -> u64 {
|
||||
(h ^ v).wrapping_mul(0x0100_0000_01b3).rotate_left(29)
|
||||
}
|
||||
|
||||
/// Objects covering more than this much of the photo gain little from a zoomed pass.
|
||||
const DETAIL_MAX_AREA: f64 = 0.4;
|
||||
|
||||
/// The part of the photo to zoom into for `seg`'s selection: its bounding box with a margin of
|
||||
/// a quarter of its size, at least 12 % of the long edge, at most 2:1, inside the photo
|
||||
/// (`aspect` = width / height). `None` when nothing is selected or it is most of the photo.
|
||||
pub fn detail_region(seg: &SegMask, aspect: f64) -> Option<[f64; 4]> {
|
||||
let logits = seg.logits()?;
|
||||
let r = seg.bounds()?;
|
||||
let side = seg.side as usize;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
|
||||
for (i, l) in logits.iter().enumerate() {
|
||||
if *l > 0.0 {
|
||||
let (x, y) = (i % side, i / side);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX {
|
||||
return None;
|
||||
}
|
||||
let (rw, rh) = (r[2] - r[0], r[3] - r[1]);
|
||||
let nx = |c: usize| r[0] + c as f64 / side as f64 * rw;
|
||||
let ny = |c: usize| r[1] + c as f64 / side as f64 * rh;
|
||||
let (bx0, by0, bx1, by1) = (nx(x0), ny(y0), nx(x1), ny(y1));
|
||||
if (bx1 - bx0) * (by1 - by0) > DETAIL_MAX_AREA {
|
||||
return None;
|
||||
}
|
||||
// in units of the photo's height: x × aspect
|
||||
let a = if aspect.is_finite() { aspect.clamp(0.1, 10.0) } else { 1.0 };
|
||||
let (cx, cy) = ((bx0 + bx1) / 2.0 * a, (by0 + by1) / 2.0);
|
||||
let size = ((bx1 - bx0) * a).max(by1 - by0);
|
||||
let m = 0.25 * size;
|
||||
let mut w = ((bx1 - bx0) * a + 2.0 * m).max(0.12 * a.max(1.0));
|
||||
let mut h = ((by1 - by0) + 2.0 * m).max(0.12 * a.max(1.0));
|
||||
w = w.max(h / 2.0).min(a);
|
||||
h = h.max(w / 2.0).min(1.0);
|
||||
let x = (cx - w / 2.0).clamp(0.0, (a - w).max(0.0));
|
||||
let y = (cy - h / 2.0).clamp(0.0, (1.0 - h).max(0.0));
|
||||
let out = [x / a, y, (x + w) / a, y + h];
|
||||
// a region nearly the whole photo isn't worth a second pass
|
||||
((out[2] - out[0]) * (out[3] - out[1]) < 0.8).then_some(out)
|
||||
}
|
||||
|
||||
/// `shape` without its detail patches (what a detail pass was computed from)
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn strip_detail(mut shape: MaskShape) -> MaskShape {
|
||||
// (nor its edge setting: moving the slider meanwhile keeps the pass)
|
||||
if let MaskShape::Object { detail, edge, .. } | MaskShape::Prompt { detail, edge, .. } = &mut shape {
|
||||
detail.clear();
|
||||
*edge = 0.0;
|
||||
}
|
||||
shape
|
||||
}
|
||||
|
||||
fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
|
||||
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The key and settings of photo `id` as the model sees it: its look without masks, uncropped.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn segment_key(&self, id: PhotoId) -> Option<(u64, lightcraft_develop::DevelopSettings)> {
|
||||
let p = self.catalog.photo(id)?;
|
||||
let mut d = (*p.develop).clone();
|
||||
d.masks.clear();
|
||||
let content = crate::media::content_key(p);
|
||||
let mut h = mix(0xcbf2_9ce4_8422_2325, d.hash64());
|
||||
h = mix(h, id.0);
|
||||
for b in content.bytes() {
|
||||
h = mix(h, u64::from(b));
|
||||
}
|
||||
Some((h, d))
|
||||
}
|
||||
|
||||
/// Clicks (in normalized photo coordinates) for the model.
|
||||
#[cfg(feature = "sam")]
|
||||
fn model_clicks(clicks: &[Click]) -> Vec<lightcraft_segment::Click> {
|
||||
clicks.iter().map(|c| lightcraft_segment::Click { x: c.at.x as f32, y: c.at.y as f32, positive: c.include }).collect()
|
||||
}
|
||||
|
||||
/// A worker request about photo `id` (the render job for its model input goes along; the
|
||||
/// worker runs it only when its cache doesn't hold the photo). `region` renders that part
|
||||
/// of the photo, from a source sharp enough for it (detail passes).
|
||||
#[cfg(feature = "sam")]
|
||||
fn segment_job(
|
||||
&mut self,
|
||||
id: PhotoId,
|
||||
kind: worker::Kind,
|
||||
tag: Tag,
|
||||
reply: std::sync::mpsc::Sender<worker::Outcome>,
|
||||
region: Option<[f64; 4]>,
|
||||
) -> Result<worker::Job, String> {
|
||||
let dir = self.segmenter.model_dir()?;
|
||||
let (key, mut d) = self.segment_key(id).ok_or("no such photo")?;
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let missing = match &p.source {
|
||||
lightcraft_catalog::Source::File { path } if !std::path::Path::new(path).exists() => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let render = match region {
|
||||
None => self.preview_job(id, INPUT_EDGE, INPUT_EDGE, false, &d).ok_or("no such photo")?,
|
||||
Some(region) => {
|
||||
d.crop.geometry =
|
||||
lightcraft_geom::CropGeometry { rect: lightcraft_geom::Rect::new(region[0], region[1], region[2], region[3]), angle: 0.0 };
|
||||
d.crop.flip_h = false;
|
||||
d.crop.flip_v = false;
|
||||
d.geometry.constrain_crop = false;
|
||||
let mut job = self.preview_job(id, INPUT_EDGE, INPUT_EDGE, true, &d).ok_or("no such photo")?;
|
||||
let frac = (region[2] - region[0]).max(region[3] - region[1]).max(0.01);
|
||||
job.level = crate::media::SourceLevel::for_size((INPUT_EDGE as f64 / frac) as usize);
|
||||
job.source = self.media.source_ref(&p, job.level);
|
||||
job
|
||||
}
|
||||
};
|
||||
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, render: Some(render), missing, kind, tag, reply })
|
||||
}
|
||||
|
||||
/// Send a request and wait for its result (CLI, MCP, tests; or when not in background mode).
|
||||
#[cfg(feature = "sam")]
|
||||
fn segment_wait(&mut self, id: PhotoId, kind: worker::Kind, region: Option<[f64; 4]>) -> Result<Option<SegMask>, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let job = self.segment_job(id, kind, Tag::Wait, tx, region)?;
|
||||
self.segmenter.worker.submit(job)?;
|
||||
match rx.recv_timeout(WAIT) {
|
||||
Ok(o) => o.result,
|
||||
Err(_) => Err("the AI model took too long; try again".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a request whose result [`Session::segment_poll`] applies.
|
||||
#[cfg(feature = "sam")]
|
||||
fn segment_queue(&mut self, id: PhotoId, kind: worker::Kind, tag: Tag, region: Option<[f64; 4]>) -> Result<(), String> {
|
||||
let reply = self.segmenter.results.0.clone();
|
||||
let job = self.segment_job(id, kind, tag, reply, region)?;
|
||||
self.segmenter.worker.submit(job)
|
||||
}
|
||||
|
||||
/// Start preparing photo `id` for AI masks (load the model, encode the image), so the first
|
||||
/// click is fast. In background mode it returns at once.
|
||||
pub fn segment_prepare(&mut self, id: PhotoId) -> Result<(), String> {
|
||||
self.segmenter.model_dir()?;
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
if self.segmenter.background {
|
||||
return self.segment_queue(id, worker::Kind::Prepare, Tag::Prepare, None);
|
||||
}
|
||||
self.segment_wait(id, worker::Kind::Prepare, None)?;
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
let _ = id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Segment the object `clicks` pick on photo `id`, waiting for the model.
|
||||
pub fn segment_clicks(&mut self, id: PhotoId, clicks: &[Click]) -> Result<SegMask, String> {
|
||||
self.segmenter.model_dir()?;
|
||||
if !clicks.iter().any(|c| c.include) {
|
||||
return Err("click the object to include it first".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
self.segment_wait(id, worker::Kind::Clicks(Self::model_clicks(clicks)), None)?.ok_or_else(|| "nothing was selected".to_string())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = id;
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment everything `text` describes on photo `id`, waiting for the model; `Ok(None)`
|
||||
/// when nothing matches.
|
||||
pub fn segment_text(&mut self, id: PhotoId, text: &str) -> Result<Option<SegMask>, String> {
|
||||
self.segmenter.model_dir()?;
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("describe what to select".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
self.segment_wait(id, worker::Kind::Text(text.to_string()), None)
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = id;
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Background mode: queue Object clicks for component `comp` of mask `mask`; the result
|
||||
/// (clicks and segmentation, one undo step) is applied by [`Session::segment_poll`].
|
||||
pub(crate) fn segment_clicks_later(&mut self, id: PhotoId, mask: u32, comp: usize, hint: Vec<Point>, exclude: Vec<Point>) -> Result<(), String> {
|
||||
self.segmenter.model_dir()?;
|
||||
if hint.is_empty() {
|
||||
return Err("click the object to include it first".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let clicks = Self::model_clicks(&clicks_of(&hint, &exclude));
|
||||
let tag = Tag::Clicks { photo: id, mask, comp, hint: hint.clone(), exclude: exclude.clone() };
|
||||
self.segment_queue(id, worker::Kind::Clicks(clicks), tag, None)?;
|
||||
self.segmenter.pending = Some(PendingClicks { photo: id, mask, comp, hint, exclude });
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = (id, mask, comp, exclude);
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Background mode: queue a Describe selection; the mask (or component, combined by `op`
|
||||
/// into mask `mask`) is created by [`Session::segment_poll`] when something matches.
|
||||
pub(crate) fn segment_text_later(&mut self, id: PhotoId, mask: Option<u32>, op: &str, name: Option<String>, text: &str) -> Result<(), String> {
|
||||
self.segmenter.model_dir()?;
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("describe what to select".into());
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let tag = Tag::NewPrompt { photo: id, mask, op: op.to_string(), name, text: text.to_string() };
|
||||
self.segment_queue(id, worker::Kind::Text(text.to_string()), tag, None)
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = (id, mask, op, name);
|
||||
Err("AI masks are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a zoomed-in pass for component `comp` of mask `mask` on photo `id` (an Object or
|
||||
/// Describe selection): the photo around the selection is rendered at a higher resolution
|
||||
/// and segmented again. In background mode [`Session::segment_poll`] applies the result;
|
||||
/// otherwise it is applied before this returns. `Ok(false)` when there is nothing to
|
||||
/// refine (no selection yet, or it is most of the photo) or a pass is running already.
|
||||
pub fn segment_detail(&mut self, id: PhotoId, mask: u32, comp: usize) -> Result<bool, String> {
|
||||
self.segmenter.model_dir()?;
|
||||
let d = self.develop_of(id).ok_or("no such photo")?;
|
||||
let shape = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()).ok_or("no such mask")?;
|
||||
let (seg, prompt) = match &shape {
|
||||
// (a document with absurdly many clicks isn't run through the model)
|
||||
MaskShape::Object { hint, exclude, seg: Some(seg), .. } if !hint.is_empty() && hint.len() + exclude.len() <= MAX_CLICKS => {
|
||||
(seg.clone(), Prompt::Clicks(clicks_of(hint, exclude)))
|
||||
}
|
||||
MaskShape::Prompt { text, seg: Some(seg), .. } => (seg.clone(), Prompt::Text(text.clone())),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?;
|
||||
let (pw, ph) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
|
||||
let aspect = if d.orientation.swaps_axes() { ph / pw } else { pw / ph };
|
||||
let Some(region) = detail_region(&seg, aspect) else { return Ok(false) };
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let kind = worker::Kind::Detail { prompt, region };
|
||||
let tag = Tag::Detail { photo: id, mask, comp, shape: strip_detail(shape) };
|
||||
if self.segmenter.background {
|
||||
if self.segmenter.detail_busy() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.segment_queue(id, kind, tag, Some(region))?;
|
||||
} else {
|
||||
let patch = self.segment_wait(id, kind, Some(region))?;
|
||||
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
|
||||
let mut polled = Polled::default();
|
||||
self.apply_outcome(outcome, &mut polled);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
let _ = (prompt, region, mask);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply finished background requests (to masks that haven't changed since) and collect
|
||||
/// their messages. Cheap when nothing is pending: call it every frame.
|
||||
pub fn segment_poll(&mut self) -> Polled {
|
||||
#[cfg_attr(not(feature = "sam"), allow(unused_mut))]
|
||||
let mut polled = Polled { messages: std::mem::take(&mut self.segmenter.messages), ..Default::default() };
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
// (before draining: a worker that is idle now has sent everything)
|
||||
let idle = !self.segmenter.busy();
|
||||
let done: Vec<worker::Outcome> = self.segmenter.results.1.try_iter().collect();
|
||||
for o in done {
|
||||
self.apply_outcome(o, &mut polled);
|
||||
}
|
||||
// clicks whose request was lost (the worker restarted) aren't pending forever
|
||||
if idle {
|
||||
self.segmenter.pending = None;
|
||||
}
|
||||
}
|
||||
polled
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
fn apply_outcome(&mut self, o: worker::Outcome, polled: &mut Polled) {
|
||||
if o.superseded {
|
||||
return;
|
||||
}
|
||||
match o.tag {
|
||||
Tag::Wait => {}
|
||||
Tag::Prepare => {
|
||||
if let Err(e) = o.result {
|
||||
polled.messages.push(e);
|
||||
}
|
||||
}
|
||||
Tag::Clicks { photo, mask, comp, hint, exclude } => {
|
||||
let latest = PendingClicks { photo, mask, comp, hint, exclude };
|
||||
if self.segmenter.pending.as_ref() != Some(&latest) {
|
||||
return; // newer clicks are on their way
|
||||
}
|
||||
self.segmenter.pending = None;
|
||||
let seg = match o.result {
|
||||
Ok(Some(seg)) => seg,
|
||||
Ok(None) => return,
|
||||
Err(e) => return polled.messages.push(e),
|
||||
};
|
||||
let Some(d) = self.develop_of(photo) else { return };
|
||||
let mut d = (*d).clone();
|
||||
let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return };
|
||||
let MaskShape::Object { edge, .. } = c.shape else { return };
|
||||
c.shape = MaskShape::Object { hint: latest.hint, exclude: latest.exclude, seg: Some(seg), detail: vec![], edge };
|
||||
match self.set_develop(photo, d, "Object Mask") {
|
||||
Ok(()) => {
|
||||
polled.changed = true;
|
||||
polled.refine = Some(mask);
|
||||
}
|
||||
Err(e) => polled.messages.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
Tag::NewPrompt { photo, mask, op, name, text } => {
|
||||
let seg = match o.result {
|
||||
Ok(Some(seg)) => seg,
|
||||
Ok(None) => return polled.messages.push(format!("Nothing matching “{text}” was found in this photo.")),
|
||||
Err(e) => return polled.messages.push(e),
|
||||
};
|
||||
if self.active() != Some(photo) {
|
||||
return polled.messages.push(format!("The photo changed before “{text}” was found; try again."));
|
||||
}
|
||||
let Ok(seg) = serde_json::to_value(seg) else { return };
|
||||
let mut p = serde_json::json!({"kind": "prompt", "text": text, "seg": seg});
|
||||
if let Some(n) = name {
|
||||
p["name"] = serde_json::json!(n);
|
||||
}
|
||||
let r = match mask {
|
||||
None => self.execute("mask.add", &p),
|
||||
Some(m) => {
|
||||
if !self.develop_of(photo).is_some_and(|d| d.masks.iter().any(|x| x.id == m)) {
|
||||
return polled.messages.push("The mask was removed before the selection was found.".into());
|
||||
}
|
||||
self.active_mask = Some(m);
|
||||
p["op"] = serde_json::json!(op);
|
||||
self.execute("mask.addComponent", &p)
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Ok(_) => {
|
||||
polled.changed = true;
|
||||
polled.refine = self.active_mask;
|
||||
}
|
||||
Err(e) => polled.messages.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
Tag::Detail { photo, mask, comp, shape } => {
|
||||
let patch = match o.result {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return,
|
||||
Err(e) => return log::warn!("SAM 3 detail pass: {e}"),
|
||||
};
|
||||
let Some(d) = self.develop_of(photo) else { return };
|
||||
let mut d = (*d).clone();
|
||||
let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return };
|
||||
if strip_detail(c.shape.clone()) != shape {
|
||||
return; // clicked again meanwhile: a newer pass will follow
|
||||
}
|
||||
match &mut c.shape {
|
||||
MaskShape::Object { detail, .. } | MaskShape::Prompt { detail, .. } => *detail = vec![patch],
|
||||
_ => return,
|
||||
}
|
||||
match self.set_develop(photo, d, "AI Mask Detail") {
|
||||
Ok(()) => polled.changed = true,
|
||||
Err(e) => log::warn!("AI mask detail: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask {
|
||||
let l: Vec<f32> =
|
||||
(0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect();
|
||||
SegMask::from_logits(side, &l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_region_frames_the_selection_with_a_margin() {
|
||||
// a small object in the middle of a square photo
|
||||
let r = detail_region(&seg_box(100, 40, 45, 50, 55), 1.0).unwrap();
|
||||
assert!(r[0] < 0.4 && r[2] > 0.5 && r[1] < 0.45 && r[3] > 0.55, "contains it: {r:?}");
|
||||
assert!(r[2] - r[0] < 0.25 && r[3] - r[1] < 0.25, "zoomed in: {r:?}");
|
||||
assert!(r.iter().all(|v| (0.0..=1.0).contains(v)));
|
||||
// at the corner: shifted inside the photo, not cut
|
||||
let r = detail_region(&seg_box(100, 0, 0, 5, 5), 1.5).unwrap();
|
||||
assert!(r[0] >= 0.0 && r[1] >= 0.0 && r[0] < 1e-9 && r[1] < 1e-9);
|
||||
// a thin object stays at most 2:1 (in photo pixels, 1.5:1 photo)
|
||||
let r = detail_region(&seg_box(100, 10, 50, 90, 52), 1.5).unwrap();
|
||||
let (w, h) = ((r[2] - r[0]) * 1.5, r[3] - r[1]);
|
||||
assert!(w / h <= 2.0 + 1e-9, "{w} × {h}");
|
||||
// a hostile aspect ratio stays sane
|
||||
assert!(detail_region(&seg_box(100, 40, 45, 50, 55), f64::NAN).is_some_and(|r| r.iter().all(|v| v.is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_region_for_nothing_or_most_of_the_photo() {
|
||||
assert!(detail_region(&seg_box(50, 0, 0, 0, 0), 1.0).is_none());
|
||||
assert!(detail_region(&seg_box(50, 2, 2, 48, 48), 1.0).is_none());
|
||||
let damaged = SegMask { side: 50, data: "?".into(), rect: None };
|
||||
assert!(detail_region(&damaged, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! The SAM 3 worker thread: it owns the model and the last encoded photo, and runs every
|
||||
//! request (load, render the model input, encode, clicks, descriptions, detail passes) in
|
||||
//! turn. The session only sends jobs and receives outcomes over channels, so no thread ever
|
||||
//! waits on a lock the model holds, and the UI thread never runs the model.
|
||||
//!
|
||||
//! Every job runs under `catch_unwind`: a panic inside candle becomes an error outcome, drops
|
||||
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
|
||||
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
|
||||
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_develop::SegMask;
|
||||
use lightcraft_segment::{Encoded, Sam3};
|
||||
|
||||
use super::{Input, Prompt, Tag};
|
||||
use crate::media::RenderJob;
|
||||
|
||||
/// Unload the model after this long without a request.
|
||||
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// What to compute.
|
||||
pub enum Kind {
|
||||
/// Load the model and encode the photo (so the first click is fast).
|
||||
Prepare,
|
||||
Clicks(Vec<lightcraft_segment::Click>),
|
||||
Text(String),
|
||||
/// A zoomed-in pass over `region` (normalized) of the photo, rendered by the job's `render`.
|
||||
Detail {
|
||||
prompt: Prompt,
|
||||
region: [f64; 4],
|
||||
},
|
||||
/// Panics (tests of the worker's recovery).
|
||||
#[cfg(test)]
|
||||
Panic,
|
||||
}
|
||||
|
||||
/// One request.
|
||||
pub struct Job {
|
||||
/// The model folder.
|
||||
pub dir: PathBuf,
|
||||
/// Which photo and look the request is about (the encode cache's key).
|
||||
pub key: u64,
|
||||
/// Renders the model input when the cache doesn't hold `key` (always for a detail pass).
|
||||
pub render: Option<RenderJob>,
|
||||
/// The photo's file, when it is missing on disk (for a clearer error if rendering fails).
|
||||
pub missing: Option<String>,
|
||||
pub kind: Kind,
|
||||
pub tag: Tag,
|
||||
pub reply: mpsc::Sender<Outcome>,
|
||||
}
|
||||
|
||||
/// A finished request: the segmentation (`None`: nothing selected / nothing to report).
|
||||
pub struct Outcome {
|
||||
pub tag: Tag,
|
||||
pub result: Result<Option<SegMask>, String>,
|
||||
/// A newer request for the same selection came in; this one was skipped.
|
||||
pub superseded: bool,
|
||||
}
|
||||
|
||||
/// Counters the session reads without locking.
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
/// Requests other than detail passes, queued or running.
|
||||
pub pending: AtomicUsize,
|
||||
/// Detail passes queued or running.
|
||||
pub detail: AtomicUsize,
|
||||
/// The model is in memory.
|
||||
pub loaded: AtomicBool,
|
||||
/// Loading the model or encoding a photo right now (the slow parts).
|
||||
pub encoding: AtomicBool,
|
||||
}
|
||||
|
||||
/// Decrements a counter when dropped (also when unwinding).
|
||||
struct Done<'a>(&'a AtomicUsize);
|
||||
|
||||
impl Drop for Done<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a flag while alive.
|
||||
struct Flag<'a>(&'a AtomicBool);
|
||||
|
||||
impl<'a> Flag<'a> {
|
||||
fn raise(f: &'a AtomicBool) -> Self {
|
||||
f.store(true, Ordering::SeqCst);
|
||||
Flag(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Flag<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's handle on the worker thread (started on the first request, restarted if it
|
||||
/// ever ends).
|
||||
#[derive(Default)]
|
||||
pub struct Worker {
|
||||
tx: Option<mpsc::Sender<Job>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
pub shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn counter(&self, job: &Job) -> &AtomicUsize {
|
||||
if matches!(job.kind, Kind::Detail { .. }) { &self.shared.detail } else { &self.shared.pending }
|
||||
}
|
||||
|
||||
/// Whether the thread has ended (it never should); its counters are then meaningless.
|
||||
fn dead(&self) -> bool {
|
||||
self.thread.as_ref().is_some_and(|t| t.is_finished())
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> usize {
|
||||
if self.dead() { 0 } else { self.shared.pending.load(Ordering::SeqCst) }
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> usize {
|
||||
if self.dead() { 0 } else { self.shared.detail.load(Ordering::SeqCst) }
|
||||
}
|
||||
|
||||
pub fn encoding(&self) -> bool {
|
||||
!self.dead() && self.shared.encoding.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn loaded(&self) -> bool {
|
||||
!self.dead() && self.shared.loaded.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Queue `job`; returns at once.
|
||||
pub fn submit(&mut self, job: Job) -> Result<(), String> {
|
||||
if self.dead() {
|
||||
// a thread that ended took its counters' meaning with it
|
||||
self.tx = None;
|
||||
self.thread = None;
|
||||
self.shared = Arc::new(Shared::default());
|
||||
}
|
||||
self.counter(&job).fetch_add(1, Ordering::SeqCst);
|
||||
let job = match &self.tx {
|
||||
Some(tx) => match tx.send(job) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(mpsc::SendError(job)) => job,
|
||||
},
|
||||
None => job,
|
||||
};
|
||||
// (re)start the thread
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let shared = self.shared.clone();
|
||||
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
|
||||
Ok(t) => {
|
||||
self.thread = Some(t);
|
||||
self.tx = Some(tx.clone());
|
||||
tx.send(job).map_err(|_| "the AI mask worker stopped".to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.counter(&job).try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
|
||||
Err(format!("could not start the AI mask worker: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The model and the last encoded photo.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
model: Option<(PathBuf, Sam3)>,
|
||||
cache: Option<(u64, Encoded)>,
|
||||
}
|
||||
|
||||
/// Whether `later` makes `job` pointless (it asks for the same selection again).
|
||||
fn supersedes(later: &Job, job: &Job) -> bool {
|
||||
match (&later.tag, &job.tag) {
|
||||
(Tag::Prepare, Tag::Prepare) => later.key == job.key,
|
||||
(Tag::Clicks { photo: a, mask: am, comp: ac, .. }, Tag::Clicks { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
|
||||
(Tag::Detail { photo: a, mask: am, comp: ac, .. }, Tag::Detail { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
let mut state = State::default();
|
||||
let mut queue: VecDeque<Job> = VecDeque::new();
|
||||
loop {
|
||||
if queue.is_empty() {
|
||||
match rx.recv_timeout(idle) {
|
||||
Ok(j) => queue.push_back(j),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if state.model.is_some() {
|
||||
state = State::default();
|
||||
shared.loaded.store(false, Ordering::SeqCst);
|
||||
log::info!("SAM 3 unloaded after {} minutes without use", idle.as_secs() / 60);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
while let Ok(j) = rx.try_recv() {
|
||||
queue.push_back(j);
|
||||
}
|
||||
let Some(mut job) = queue.pop_front() else { continue };
|
||||
let is_detail = matches!(job.kind, Kind::Detail { .. });
|
||||
let _done = Done(if is_detail { &shared.detail } else { &shared.pending });
|
||||
if queue.iter().any(|later| supersedes(later, &job)) {
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
|
||||
continue;
|
||||
}
|
||||
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::error!("SAM 3 panicked: {why}");
|
||||
// don't trust what the model kept: load it again next time
|
||||
state = State::default();
|
||||
Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
|
||||
}
|
||||
};
|
||||
if result.is_err() && !is_detail {
|
||||
state.cache = None;
|
||||
}
|
||||
shared.loaded.store(state.model.is_some(), Ordering::SeqCst);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
|
||||
}
|
||||
}
|
||||
|
||||
/// The model for `dir`, loading it when needed.
|
||||
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
|
||||
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
|
||||
state.model = None;
|
||||
state.cache = None;
|
||||
let _busy = Flag::raise(&shared.encoding);
|
||||
let t = web_time::Instant::now();
|
||||
let m = Sam3::load(dir).map_err(|e| e.to_string())?;
|
||||
log::info!("SAM 3 loaded in {:?}", t.elapsed());
|
||||
state.model = Some((dir.to_path_buf(), m));
|
||||
shared.loaded.store(true, Ordering::SeqCst);
|
||||
}
|
||||
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
|
||||
}
|
||||
|
||||
/// Render the model input.
|
||||
fn render(job: &mut Job) -> Result<Input, String> {
|
||||
let r = job.render.take().ok_or("the photo is not available")?.run();
|
||||
let img = match r.rendered {
|
||||
Ok(r) => r.image,
|
||||
Err(e) => {
|
||||
return Err(match &job.missing {
|
||||
Some(path) => format!(
|
||||
"This photo's file is missing (moved or deleted), so it can't be analyzed for AI masks: {path}. Library ▸ Find Missing Photos can relink it."
|
||||
),
|
||||
None => format!("couldn't render the photo for AI masks: {e}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
let rgb = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
Ok(Input { rgb, w: img.width, h: img.height })
|
||||
}
|
||||
|
||||
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
|
||||
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
|
||||
model(state, shared, &job.dir)?;
|
||||
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
|
||||
state.cache = None;
|
||||
let input = render(job)?;
|
||||
let _busy = Flag::raise(&shared.encoding);
|
||||
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
|
||||
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
|
||||
state.cache = Some((job.key, enc));
|
||||
}
|
||||
let State { model, cache } = state;
|
||||
match (model.as_mut(), cache.as_mut()) {
|
||||
(Some((_, m)), Some((_, e))) => Ok((m, e)),
|
||||
_ => Err("the model is not ready".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every phrase of `text` ("car, road"), merged (the per-pixel maximum); `None` when nothing
|
||||
/// matches.
|
||||
fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> Result<Option<Vec<f32>>, String> {
|
||||
let mut merged: Option<Vec<f32>> = None;
|
||||
for phrase in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
if let Some(p) = model.segment_text(enc, phrase, 0.5).map_err(|e| e.to_string())? {
|
||||
merged = Some(match merged {
|
||||
None => p.logits,
|
||||
Some(m) => m.iter().zip(&p.logits).map(|(a, b)| a.max(*b)).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
|
||||
let side = lightcraft_segment::MASK_SIDE;
|
||||
match std::mem::replace(&mut job.kind, Kind::Prepare) {
|
||||
Kind::Prepare => {
|
||||
encoded(state, shared, job)?;
|
||||
Ok(None)
|
||||
}
|
||||
Kind::Clicks(clicks) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
|
||||
Ok(Some(SegMask::from_logits(side, &p.logits)))
|
||||
}
|
||||
Kind::Text(text) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
Kind::Panic => panic!("a test panic"),
|
||||
Kind::Detail { prompt, region } => {
|
||||
let input = render(job)?;
|
||||
let m = model(state, shared, &job.dir)?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
|
||||
if !(rw > 0.0 && rh > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let logits = match prompt {
|
||||
Prompt::Clicks(clicks) => {
|
||||
let inside: Vec<lightcraft_segment::Click> = clicks
|
||||
.iter()
|
||||
.map(|c| ((c.at.x - region[0]) / rw, (c.at.y - region[1]) / rh, c.include))
|
||||
.filter(|(x, y, _)| (0.0..=1.0).contains(x) && (0.0..=1.0).contains(y))
|
||||
.map(|(x, y, positive)| lightcraft_segment::Click { x: x as f32, y: y as f32, positive })
|
||||
.collect();
|
||||
if !inside.iter().any(|c| c.positive) {
|
||||
return Ok(None);
|
||||
}
|
||||
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
|
||||
}
|
||||
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
|
||||
};
|
||||
log::info!("SAM 3 detail pass ({}×{}) in {:?}", input.w, input.h, t.elapsed());
|
||||
Ok(logits.map(|l| SegMask::from_logits_in(side, &l, region)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn job(tag: Tag, reply: &mpsc::Sender<Outcome>) -> Job {
|
||||
// an empty folder: loading fails with an error (no model files)
|
||||
let dir = std::env::temp_dir().join(format!("lc-sam3-worker-none-{}", std::process::id()));
|
||||
Job { dir, key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_come_back_and_the_counters_return_to_zero() {
|
||||
let mut w = Worker::default();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
for _ in 0..3 {
|
||||
w.submit(job(Tag::Wait, &tx)).unwrap();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
let o = rx.recv_timeout(Duration::from_secs(30)).unwrap();
|
||||
assert!(o.result.unwrap_err().contains("not found"));
|
||||
}
|
||||
let t = web_time::Instant::now();
|
||||
while w.pending() > 0 && t.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert_eq!(w.pending(), 0);
|
||||
assert!(!w.encoding() && !w.loaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_a_job_is_an_error_and_the_worker_carries_on() {
|
||||
let shared = Shared::default();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (jtx, jrx) = mpsc::channel();
|
||||
let mut j = job(Tag::Wait, &tx);
|
||||
j.kind = Kind::Panic;
|
||||
jtx.send(j).unwrap();
|
||||
jtx.send(job(Tag::Wait, &tx)).unwrap();
|
||||
shared.pending.store(2, Ordering::SeqCst);
|
||||
drop(jtx);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
let first = rx.recv().unwrap();
|
||||
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
|
||||
let second = rx.recv().unwrap();
|
||||
assert!(second.result.is_err(), "the next job still runs");
|
||||
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
|
||||
assert!(!shared.encoding.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (jtx, jrx) = mpsc::channel();
|
||||
let clicks = |n: usize| Tag::Clicks {
|
||||
photo: lightcraft_catalog::PhotoId(1),
|
||||
mask: 1,
|
||||
comp: 0,
|
||||
hint: vec![lightcraft_geom::Point::new(0.5, 0.5); n],
|
||||
exclude: vec![],
|
||||
};
|
||||
for n in 1..=3 {
|
||||
jtx.send(job(clicks(n), &tx)).unwrap();
|
||||
}
|
||||
drop(jtx);
|
||||
let shared = Shared::default();
|
||||
shared.pending.store(3, Ordering::SeqCst);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
let out: Vec<Outcome> = rx.try_iter().collect();
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
|
||||
assert_eq!(shared.pending.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ pub struct SidecarData {
|
||||
pub alt_text: Option<String>,
|
||||
pub extended_description: Option<String>,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
pub regions: Option<Vec<lightcraft_meta::Region>>,
|
||||
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
|
||||
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
|
||||
pub captured: Option<String>,
|
||||
@@ -133,6 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
// A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that
|
||||
// knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's.
|
||||
// One without it (most writers, LightCraft's own included, which keeps another app's
|
||||
// `mwg-rs:Regions` byte for byte but never writes one) says nothing about regions, and the
|
||||
// photo's are kept.
|
||||
regions: d.values.contains_key("mwg-rs:Regions").then(|| m.regions.clone()),
|
||||
captured: m.capture_time.map(|d| d.to_iso()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -202,6 +209,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
if let Some(k) = &sc.keywords {
|
||||
m.keywords = k.clone();
|
||||
}
|
||||
if let Some(r) = &sc.regions {
|
||||
m.regions = r.clone();
|
||||
}
|
||||
let develop = match &sc.develop {
|
||||
Some(DevelopPatch::Full(s)) => (**s).clone(),
|
||||
Some(DevelopPatch::Partial(v)) => {
|
||||
|
||||
+60
-13
@@ -1,13 +1,16 @@
|
||||
//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB)
|
||||
//! kept in the library, so photos stay editable — and exportable at proxy size — while their
|
||||
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header.
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
|
||||
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
|
||||
//! at render time and is not baked into the pixels.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::Photo;
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_pipeline::tone::CameraTone;
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
const MAGIC: &[u8] = b"LCSP1\n";
|
||||
@@ -18,8 +21,11 @@ pub fn file_name(p: &Photo) -> String {
|
||||
format!("{:032x}.lcsp", h.0)
|
||||
}
|
||||
|
||||
/// Encode a source image as a smart preview.
|
||||
pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
|
||||
const HEADER_MAX: u64 = 4096;
|
||||
|
||||
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
|
||||
let scale = if lum.is_empty() {
|
||||
@@ -47,22 +53,29 @@ pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = MAGIC.to_vec();
|
||||
out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes());
|
||||
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale});
|
||||
if let Some(t) = tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
}
|
||||
out.extend_from_slice(head.to_string().as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(&jpg);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode a smart preview back into a source image.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, String> {
|
||||
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
|
||||
/// curve is ignored, like a missing one).
|
||||
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
|
||||
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
|
||||
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
|
||||
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
|
||||
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
|
||||
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
|
||||
// the decoder undoes the sRGB encoding; the values are the source's own primaries
|
||||
let mut img = d.image;
|
||||
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
|
||||
Ok(img)
|
||||
Ok((img, tone))
|
||||
}
|
||||
|
||||
/// Where a library keeps its smart previews.
|
||||
@@ -157,8 +170,8 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
pub fn is_valid(path: &Path) -> bool {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let Ok(mut f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::with_capacity(256);
|
||||
if (&mut f).take(256).read_to_end(&mut head).is_err() {
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
if (&mut f).take(HEADER_MAX).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
@@ -175,9 +188,9 @@ pub fn is_valid(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
decode(&b).map(Arc::new)
|
||||
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -228,7 +241,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let good = encode(&img).unwrap();
|
||||
let good = encode(&img, None).unwrap();
|
||||
std::fs::write(a.join("1.lcsp"), &good).unwrap();
|
||||
assert!(is_valid(&a.join("1.lcsp")));
|
||||
for cut in [good.len() - 1, good.len() / 2, 10, 0] {
|
||||
@@ -242,11 +255,45 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_travels_with_the_proxy() {
|
||||
let tone = CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.17f32.powi(i as i32);
|
||||
[x, 0.95 * (1.0 - (-2.7 * x).exp())]
|
||||
}))
|
||||
.unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let bytes = encode(&img, Some(&tone)).unwrap();
|
||||
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
|
||||
let dir = temp("tone");
|
||||
let path = dir.join("t.lcsp");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
// the longer header is still read by the validity check, and the curve by `load`
|
||||
assert!(is_valid(&path));
|
||||
let loaded = load(&path).unwrap();
|
||||
assert_eq!(loaded.camera_tone, Some(tone));
|
||||
let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() };
|
||||
assert_eq!(loaded.info_or(header).camera_tone, Some(tone));
|
||||
// a hostile curve (reversing knots) is ignored, not trusted
|
||||
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
|
||||
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
|
||||
let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap();
|
||||
knots.swap(3, 4);
|
||||
head["tone"]["knots"] = serde_json::json!(knots);
|
||||
let mut hostile = MAGIC.to_vec();
|
||||
hostile.extend_from_slice(head.to_string().as_bytes());
|
||||
hostile.extend_from_slice(&bytes[nl..]);
|
||||
let (pixels, tone) = decode(&hostile).unwrap();
|
||||
assert_eq!((pixels.width, tone), (img.width, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
let back = decode(&encode(&img).unwrap()).unwrap();
|
||||
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
|
||||
assert_eq!((back.width, back.height), (img.width, img.height));
|
||||
assert!(tone.is_none());
|
||||
let mut err = 0.0f64;
|
||||
for (a, b) in img.data.iter().zip(&back.data) {
|
||||
for k in 0..3 {
|
||||
|
||||
@@ -10,6 +10,30 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
||||
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
|
||||
// A catalog made by the old generic-matrix Kelvin inference.
|
||||
p.as_shot_wb = Some((6829.0, -127.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.wb.mode, WbMode::Custom);
|
||||
assert_eq!(d.wb.temp, 8000.0);
|
||||
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
|
||||
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
|
||||
assert_eq!(active_dev(&s).wb.temp, 6500.0);
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
@@ -703,3 +727,93 @@ fn curve_reset_by_channel_and_whole() {
|
||||
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
|
||||
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_masks_without_the_model_explain_themselves() {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
// an Object mask that could never be computed is refused, not left empty
|
||||
let e = s.execute("mask.add", &json!({"kind": "object"})).unwrap_err().to_string();
|
||||
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
|
||||
assert!(!e.contains("invalid parameters"), "shown as is: {e}");
|
||||
assert!(s.develop_of(id).unwrap().masks.is_empty());
|
||||
// an empty Object component (as an older document may hold) still takes clicks
|
||||
let shape = lightcraft_develop::MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 };
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
d.masks.push(lightcraft_develop::Mask {
|
||||
id: 1,
|
||||
components: vec![lightcraft_develop::MaskComponent { name: None, op: lightcraft_develop::MaskOp::Add, invert: false, shape }],
|
||||
..Default::default()
|
||||
});
|
||||
s.set_develop(id, d, "test").unwrap();
|
||||
s.active_mask = Some(1);
|
||||
// clicks and descriptions need the model: an error that says what's missing, nothing changed
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
|
||||
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
|
||||
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
|
||||
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
|
||||
let empty = std::env::temp_dir().join(format!("lc-no-sam3-{}", std::process::id()));
|
||||
s.segmenter.dir = Some(empty.clone());
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
|
||||
assert!(e.contains(&empty.display().to_string()) || e.contains("not available"), "names the folder: {e}");
|
||||
assert_eq!(s.develop_of(id).unwrap().masks.len(), 1);
|
||||
// bad input is an error, not a crash
|
||||
assert!(s.execute("mask.objectPoint", &json!({"x": 1.5, "y": 0.5})).is_err());
|
||||
assert!(s.execute("mask.add", &json!({"kind": "prompt", "text": " "})).is_err());
|
||||
}
|
||||
|
||||
/// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the
|
||||
/// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic.
|
||||
#[test]
|
||||
fn face_job_crops_a_square_inside_the_photo() {
|
||||
use lightcraft_geom::Rect;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let (w, h) = {
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
(f64::from(p.width), f64::from(p.height))
|
||||
};
|
||||
let faces = [
|
||||
Rect { x0: 0.40, y0: 0.30, x1: 0.50, y1: 0.45 },
|
||||
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
|
||||
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
|
||||
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
|
||||
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
|
||||
];
|
||||
for face in faces {
|
||||
let job = s.face_job(id, face, 256).expect("a job for a photo in the library");
|
||||
let r = job.settings.crop.geometry.rect;
|
||||
assert!((0.0..=1.0).contains(&r.x0) && (0.0..=1.0).contains(&r.y0) && r.x1 <= 1.0 + 1e-9 && r.y1 <= 1.0 + 1e-9, "{face:?} → {r:?}");
|
||||
assert!(r.x1 > r.x0 && r.y1 > r.y0, "{face:?} → {r:?}");
|
||||
let (pw, ph) = ((r.x1 - r.x0) * w, (r.y1 - r.y0) * h);
|
||||
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in pixels: {face:?} → {pw} × {ph}");
|
||||
assert_eq!(job.settings.crop.geometry.angle, 0.0);
|
||||
}
|
||||
// a face around (0.45, 0.375) is centred in its crop (room to spare on every side)
|
||||
let r = s.face_job(id, faces[0], 256).unwrap().settings.crop.geometry.rect;
|
||||
assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}");
|
||||
assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none());
|
||||
}
|
||||
|
||||
/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box
|
||||
/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels.
|
||||
#[test]
|
||||
fn face_job_follows_rotate_right() {
|
||||
use lightcraft_geom::Rect;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let (w, h) = {
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
(f64::from(p.width), f64::from(p.height))
|
||||
};
|
||||
// a small face left of centre on the upright photo
|
||||
let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 };
|
||||
s.execute("photo.rotateRight", &json!({})).unwrap();
|
||||
let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect;
|
||||
// a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325)
|
||||
assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}");
|
||||
// the rotated photo is h × w pixels
|
||||
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
|
||||
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
|
||||
}
|
||||
@@ -323,3 +323,50 @@ fn a_failed_export_write_keeps_the_previous_file() {
|
||||
assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_protects_the_target_of_an_imported_symlink() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original");
|
||||
drop(original_session);
|
||||
let alias = dir.join("alias.jpg");
|
||||
std::os::unix::fs::symlink(&src, &alias).unwrap();
|
||||
let sidecar = dir.join("metadata.xmp");
|
||||
std::fs::write(&sidecar, b"<x/>").unwrap();
|
||||
std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
|
||||
let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) };
|
||||
let result = disk_batch(&mut s, id, &o, &to);
|
||||
assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||||
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
|
||||
assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err());
|
||||
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);
|
||||
}
|
||||
@@ -144,11 +144,10 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let dir = temp_dir("missing-content");
|
||||
/// Photos a.png, b.png, c.png imported from `old`, then: a renamed into `moved`; b renamed, with
|
||||
/// an impostor (same name and size, other bytes) under its old name; c gone, with two impostors.
|
||||
fn renamed_and_ambiguous_scene(tag: &str) -> (std::path::PathBuf, Session) {
|
||||
let dir = temp_dir(tag);
|
||||
std::fs::create_dir_all(dir.join("old")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
|
||||
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
|
||||
@@ -156,33 +155,92 @@ fn find_missing_matches_renamed_files_by_content() {
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
|
||||
// an impostor: same name and size, other bytes
|
||||
let impostor = |src: &Path, dst: &Path| {
|
||||
let mut b = std::fs::read(src).unwrap();
|
||||
let k = b.len() - 20;
|
||||
b[k] ^= 0xff;
|
||||
std::fs::write(dst, b).unwrap();
|
||||
};
|
||||
// a: renamed; b: renamed, with an impostor under its old name; c: gone, two impostors
|
||||
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
|
||||
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
|
||||
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
|
||||
std::fs::remove_file(dir.join("old/c.png")).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-content");
|
||||
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
|
||||
let found = r["found"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{r}");
|
||||
let to = |name: &str| {
|
||||
found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone()))
|
||||
};
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor");
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved").join("Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(
|
||||
to("b.png"),
|
||||
Some((dir.join("moved").join("real").join("Trip-002.png").to_string_lossy().to_string(), json!("content"))),
|
||||
"not the impostor"
|
||||
);
|
||||
assert_eq!(r["missing"], 1);
|
||||
assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #104: the app plans Find Missing on a worker thread (no session) and relinks the plan
|
||||
/// afterwards; that gives exactly what the synchronous command gives — renamed files by content,
|
||||
/// impostors and look-alikes skipped (`ambiguous`).
|
||||
#[test]
|
||||
fn find_missing_planned_on_a_worker_matches_the_command() {
|
||||
use crate::cmd::missing::{find_candidates, plan_find_missing};
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-worker");
|
||||
let folder = dir.join("moved").to_string_lossy().to_string();
|
||||
let candidates = find_candidates(&s.catalog);
|
||||
let f = folder.clone();
|
||||
let plan = std::thread::spawn(move || plan_find_missing(&candidates, &f)).join().unwrap().unwrap();
|
||||
assert_eq!((plan.found.len(), plan.missing, plan.ambiguous.len()), (2, 1, 1), "{plan:?}");
|
||||
let applied = s.execute("library.findMissing", &plan.to_json()).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(3), "undo restores all three");
|
||||
let direct = s.execute("library.findMissing", &json!({"folder": folder})).unwrap();
|
||||
assert_eq!(applied, direct);
|
||||
assert_eq!(direct["found"].as_array().map(Vec::len), Some(2), "{direct}");
|
||||
assert_eq!(direct["ambiguous"].as_array().map(Vec::len), Some(1), "{direct}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #104: a plan made on a worker is re-checked when it is applied: a photo relinked
|
||||
/// meanwhile is left alone, and a file that is now another photo's original isn't used.
|
||||
#[test]
|
||||
fn find_missing_plan_is_rechecked_when_applied() {
|
||||
use crate::cmd::missing::{find_candidates, plan_find_missing};
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-recheck");
|
||||
let plan = plan_find_missing(&find_candidates(&s.catalog), &dir.join("moved").to_string_lossy()).unwrap();
|
||||
let to = |name: &str| plan.found.iter().find(|f| f.from.ends_with(name)).unwrap().clone();
|
||||
let (a, b) = (to("a.png"), to("b.png"));
|
||||
// meanwhile: a is relinked by hand (elsewhere), and the ambiguous c is pointed at b's file
|
||||
std::fs::copy(&a.to, dir.join("a-elsewhere.png")).unwrap();
|
||||
s.execute("photo.relink", &json!({"id": a.id.0, "path": dir.join("a-elsewhere.png").to_string_lossy()})).unwrap();
|
||||
let c = plan.ambiguous[0].id;
|
||||
s.execute("photo.relink", &json!({"id": c.0, "path": b.to})).unwrap();
|
||||
let r = s.execute("library.findMissing", &plan.to_json()).unwrap();
|
||||
assert_eq!(r["found"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert_eq!(r["ambiguous"].as_array().map(Vec::len), Some(0), "c isn't missing any more: {r}");
|
||||
assert_eq!(r["missing"], 1, "b stays missing: its file is c's now: {r}");
|
||||
let path_of = |id: lightcraft_catalog::PhotoId| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(path_of(a.id), dir.join("a-elsewhere.png").to_string_lossy());
|
||||
assert_eq!(path_of(b.id), b.from);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_files_are_found_and_relinked() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
|
||||
@@ -425,6 +483,20 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() {
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole");
|
||||
// the app's background build (issue #104) checks and repairs the same way, on a worker thread
|
||||
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
|
||||
let r = s.execute("library.smartPreviews", &json!({"background": true})).unwrap();
|
||||
assert_eq!(r["what"], "smart previews", "{r}");
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = loop {
|
||||
let r = s.execute("library.previewProgress", &json!({})).unwrap();
|
||||
if r["running"] == false || t0.elapsed() > std::time::Duration::from_secs(60) {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -514,7 +586,7 @@ fn folders_rename_and_move_with_their_photos() {
|
||||
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
|
||||
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"], 1);
|
||||
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
|
||||
assert_eq!(path(&s), root.join("Archive").join("Italy 2026").join("one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -273,7 +273,7 @@ fn import_review_preview_and_options() {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// c.png is already in the library
|
||||
s.execute("library.import", &json!({"paths": [src.join("sub/c.png").to_string_lossy()]})).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap();
|
||||
let n0 = s.catalog.len();
|
||||
let undo0 = s.undo.len();
|
||||
let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
@@ -466,3 +466,88 @@ fn tracklog_auto_tag() {
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- random sort: Given the library, When the user picks Random, Then the grid is a stable shuffle
|
||||
|
||||
#[test]
|
||||
fn random_sort_is_stable_until_reshuffled() {
|
||||
use lightcraft_catalog::SortKey;
|
||||
let mut s = Session::with_demo();
|
||||
let dated = s.visible_cloned();
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 11})).unwrap();
|
||||
assert_eq!((s.sort.key, s.sort.seed), (SortKey::Random, 11));
|
||||
let a = s.visible_cloned();
|
||||
assert_ne!(a, dated);
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort();
|
||||
let mut all = dated.clone();
|
||||
all.sort();
|
||||
assert_eq!(sorted, all, "a permutation of the same photos");
|
||||
// an unrelated catalog change must not move anything
|
||||
let first = a[0];
|
||||
s.execute("photo.rate", &json!({"rating": 4, "ids": [first.0]})).unwrap();
|
||||
assert_eq!(s.visible_cloned(), a, "same seed, same grid after an edit");
|
||||
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers for a shuffle");
|
||||
// Reshuffle picks a new seed (and works even with a frozen clock)
|
||||
s.execute("library.shuffle", &json!({})).unwrap();
|
||||
let seed1 = s.sort.seed;
|
||||
assert_eq!(s.sort.key, SortKey::Random);
|
||||
assert_ne!(seed1, 11);
|
||||
assert_ne!(s.visible_cloned(), a);
|
||||
s.execute("library.shuffle", &json!({"seed": null})).unwrap();
|
||||
assert_ne!(s.sort.seed, seed1, "every reshuffle is new, and a null seed means none given");
|
||||
assert!(s.sort.seed < 1 << 53, "seeds survive a round trip through a JSON double");
|
||||
// an explicit seed is reproducible
|
||||
s.execute("library.shuffle", &json!({"seed": 11})).unwrap();
|
||||
assert_eq!(s.visible_cloned(), a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_rejects_bad_params() {
|
||||
let mut s = Session::with_demo();
|
||||
let before = s.sort;
|
||||
assert!(s.execute("library.sort", &json!({"seed": "x"})).is_err());
|
||||
assert!(s.execute("library.sort", &json!({"seed": -1})).is_err());
|
||||
assert!(s.execute("library.shuffle", &json!({"seed": 1.5})).is_err());
|
||||
assert!(s.execute("library.sort", &json!({"seed": 1u64 << 53})).is_err(), "beyond what a JSON double holds exactly");
|
||||
assert!(s.execute("library.sort", &json!({"seed": (1u64 << 53) - 1})).is_ok());
|
||||
assert_eq!(s.sort.key, before.key, "a rejected command leaves the sort alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_applies_to_albums_too() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 5})).unwrap();
|
||||
let all = s.visible_cloned();
|
||||
// manual order shows newest-first (descending), i.e. the reverse of this insertion order, so it
|
||||
// differs from the shuffle unless Random really overrides it
|
||||
let ids: Vec<u64> = all.iter().take(12).map(|i| i.0).collect();
|
||||
let album = s.execute("album.create", &json!({"name": "Shuffled"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
|
||||
let in_album = s.visible_cloned();
|
||||
assert_eq!(in_album.len(), 12);
|
||||
let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect();
|
||||
assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order");
|
||||
}
|
||||
|
||||
/// Choosing Random from another sort is a fresh shuffle (not the fixed seed-0 order); picking it
|
||||
/// again, or changing only the direction, keeps the shuffle on screen; an explicit seed wins.
|
||||
#[test]
|
||||
fn choosing_random_starts_a_fresh_shuffle() {
|
||||
let mut s = Session::with_demo();
|
||||
assert_eq!(s.sort.seed, 0);
|
||||
s.execute("library.sort", &json!({"key": "random"})).unwrap();
|
||||
let first = s.sort.seed;
|
||||
assert_ne!(first, 0, "the first Random is not the fixed seed-0 order");
|
||||
s.execute("library.sort", &json!({"key": "random"})).unwrap();
|
||||
s.execute("library.sort", &json!({"ascending": true})).unwrap();
|
||||
assert_eq!(s.sort.seed, first, "already random: the shuffle stays");
|
||||
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
|
||||
s.execute("library.sort", &json!({"key": "random"})).unwrap();
|
||||
assert_ne!(s.sort.seed, first, "coming back to Random reshuffles");
|
||||
s.execute("library.sort", &json!({"key": "rating"})).unwrap();
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 42})).unwrap();
|
||||
assert_eq!(s.sort.seed, 42, "an explicit seed is honoured");
|
||||
assert!(s.sort.seed < 1 << 53);
|
||||
}
|
||||
@@ -201,6 +201,25 @@ fn album_source_reports_in_library_state_and_persists_the_view() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A library opens unfiltered: the source and sort come back, a filter from the last session does not
|
||||
/// (it would hide photos behind a small badge).
|
||||
#[test]
|
||||
fn filters_are_not_restored_when_a_library_reopens() {
|
||||
let dir = temp_dir("filter-not-restored");
|
||||
let mut s = open(&dir);
|
||||
let all = s.visible_cloned().len();
|
||||
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 5, "date": "2026-01-16", "person": "Jane Doe"})).unwrap();
|
||||
assert!(s.visible_cloned().len() < all);
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let mut s2 = open(&dir);
|
||||
assert_eq!(s2.filter, lightcraft_catalog::Filter::default());
|
||||
assert_eq!(s2.visible_cloned().len(), all);
|
||||
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
|
||||
let dir = temp_dir("chips");
|
||||
@@ -223,3 +242,17 @@ fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
|
||||
}
|
||||
assert_eq!(s.filter, Default::default());
|
||||
}
|
||||
|
||||
/// A shuffle is part of the saved view: the same seed (and so the same grid) is back on the next launch.
|
||||
#[test]
|
||||
fn random_sort_persists_with_the_view() {
|
||||
let dir = temp_dir("random-view");
|
||||
let mut s = open(&dir);
|
||||
s.execute("library.sort", &json!({"key": "random", "seed": 123_456_789})).unwrap();
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let s2 = open(&dir);
|
||||
assert_eq!(s2.sort.key, lightcraft_catalog::SortKey::Random);
|
||||
assert_eq!(s2.sort.seed, 123_456_789);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! persistence in prefs.json.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::MediaKind;
|
||||
use lightcraft_develop::Preset;
|
||||
@@ -263,3 +264,56 @@ fn curve_presets_save_apply_export_import_and_persist() {
|
||||
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() {
|
||||
let lib = temp_dir("resize");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let cache = s.media.rendered.clone();
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
|
||||
cache.put(key, Arc::new(img));
|
||||
let generation = cache.generation();
|
||||
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
|
||||
// the same cache object (the UI keeps its textures), the same generation, the new budget
|
||||
assert!(Arc::ptr_eq(&cache, &s.media.rendered));
|
||||
assert_eq!(s.media.rendered.generation(), generation);
|
||||
assert_eq!(s.media.rendered.disk().unwrap().budget(), 300 << 20);
|
||||
assert!(s.media.rendered.get(key).is_some());
|
||||
// an explicit clear still invalidates
|
||||
s.execute("library.clearPreviews", &json!({})).unwrap();
|
||||
assert_ne!(s.media.rendered.generation(), generation);
|
||||
assert!(s.media.rendered.get(key).is_none());
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_clear() {
|
||||
let lib = temp_dir("reopen-writer");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
// a thumbnail job still holding the first open's cache object
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
let (old, key) = job.cache.clone().unwrap();
|
||||
// a thumbnail written before the reopen is still valid afterwards
|
||||
let kept = lightcraft_preview::hash_bytes(b"kept thumbnail");
|
||||
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
|
||||
old.put(kept, Arc::new(img.clone()));
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(!Arc::ptr_eq(&old, &s.media.rendered));
|
||||
assert!(s.media.rendered.get(kept).is_some(), "a plain reopen keeps the disk cache");
|
||||
s.execute("library.clearPreviews", &json!({})).unwrap();
|
||||
// the old job finishes: its render must not land in the cleared directory
|
||||
let r = job.run();
|
||||
assert!(r.rendered.is_ok());
|
||||
old.put(kept, Arc::new(img));
|
||||
let fresh = lightcraft_preview::PreviewCache::with_disk(1 << 20, &lib.join("thumbs"), 1 << 30);
|
||||
assert!(fresh.get(key).is_none(), "stale thumbnail written through the replaced cache");
|
||||
assert!(fresh.get(kept).is_none(), "stale put through the replaced cache");
|
||||
assert!(s.media.rendered.get(key).is_none());
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! AI masks without the SAM 3 model (it is never required): every request is a clear error that
|
||||
//! comes back at once, nothing waits on the network or the model, a damaged model is an error
|
||||
//! (not a crash or a stuck "busy"), and the download command needs consent and fails cleanly.
|
||||
//! No test touches the internet: downloads go to a local server.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::segment::{NOT_INSTALLED, Segmenter};
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-sam3-engine-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// A folder that looks installed but holds no usable model.
|
||||
fn damaged_model(name: &str) -> std::path::PathBuf {
|
||||
let d = tmp(name);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("model.safetensors"), b"\x10\x00\x00\x00\x00\x00\x00\x00{not json at all}").unwrap();
|
||||
std::fs::write(d.join("vocab.json"), b"{}").unwrap();
|
||||
std::fs::write(d.join("merges.txt"), b"#version: 0.2\n").unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Poll until the worker is idle (or `limit` passes), collecting messages.
|
||||
fn settle(s: &mut Session, limit: Duration) -> Vec<String> {
|
||||
let t = Instant::now();
|
||||
let mut messages = Vec::new();
|
||||
loop {
|
||||
let p = s.segment_poll();
|
||||
messages.extend(p.messages);
|
||||
if !s.segmenter.busy() && !s.segmenter.detail_busy() {
|
||||
messages.extend(s.segment_poll().messages);
|
||||
return messages;
|
||||
}
|
||||
assert!(t.elapsed() < limit, "the worker never finished");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_model_commands_without_the_model() {
|
||||
let mut s = Session::with_demo();
|
||||
let st = s.execute("segment.model.status", &json!({})).unwrap();
|
||||
assert_eq!(st["available"], Segmenter::AVAILABLE);
|
||||
assert_eq!(st["installed"], false);
|
||||
assert_eq!(st["download"]["running"], false);
|
||||
assert!(st["license"].as_str().unwrap().contains("SAM License"));
|
||||
// downloading needs the user's consent, and names the size and the licence
|
||||
let e = s.execute("segment.model.download", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("acknowledged") && e.contains("SAM License") && e.contains("GB"), "{e}");
|
||||
let e = s.execute("segment.model.download", &json!({"acknowledged": "yes"})).unwrap_err().to_string();
|
||||
assert!(e.contains("acknowledged"), "{e}");
|
||||
assert_eq!(s.execute("segment.model.cancel", &json!({})).unwrap()["cancelled"], false);
|
||||
if !Segmenter::AVAILABLE {
|
||||
assert!(s.execute("segment.model.download", &json!({"acknowledged": true})).is_err());
|
||||
return;
|
||||
}
|
||||
// no folder, then no download location: clear errors, nothing started
|
||||
let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("no folder"), "{e}");
|
||||
let dir = tmp("nomirrors");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
s.segmenter.mirrors_file = Some(dir.join("none.txt"));
|
||||
if s.segmenter.mirrors().is_empty() {
|
||||
let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("LIGHTCRAFT_SAM3_MIRRORS"), "{e}");
|
||||
}
|
||||
assert!(!s.segmenter.download_status().running);
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
#[test]
|
||||
fn a_failing_download_ends_with_an_error_and_never_blocks() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
// a local mirror that has nothing
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}/sam3", l.local_addr().unwrap());
|
||||
std::thread::spawn(move || {
|
||||
for mut c in l.incoming().flatten() {
|
||||
let mut r = BufReader::new(c.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
while r.read_line(&mut line).unwrap_or(0) > 2 {
|
||||
line.clear();
|
||||
}
|
||||
let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n");
|
||||
}
|
||||
});
|
||||
let dir = tmp("download404");
|
||||
let mirrors = dir.with_extension("mirrors.txt");
|
||||
std::fs::write(&mirrors, format!("# test mirror\n{base}\n")).unwrap();
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
s.segmenter.mirrors_file = Some(mirrors.clone());
|
||||
let t = Instant::now();
|
||||
let r = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
|
||||
assert_eq!(r["started"], true);
|
||||
assert!(t.elapsed() < Duration::from_secs(1), "starting a download returns at once");
|
||||
// a second request while it runs starts nothing
|
||||
let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
|
||||
assert!(again["started"] == false || again["downloading"] == false);
|
||||
let t = Instant::now();
|
||||
while s.segmenter.download_status().running {
|
||||
assert!(t.elapsed() < Duration::from_secs(30), "the download never ended");
|
||||
// AI mask requests meanwhile: an error at once, never a wait
|
||||
let q = Instant::now();
|
||||
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
|
||||
assert!(e.contains(NOT_INSTALLED), "{e}");
|
||||
assert!(q.elapsed() < Duration::from_millis(500));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let st = s.segmenter.download_status();
|
||||
assert!(!st.finished);
|
||||
let e = st.error.unwrap();
|
||||
assert!(e.contains("not found"), "{e}");
|
||||
assert!(!s.segmenter.installed());
|
||||
// nothing was left behind under a real name, and no error page was saved
|
||||
for f in ["model.safetensors", "vocab.json", "merges.txt", "vocab.json.part"] {
|
||||
assert!(!dir.join(f).exists(), "{f}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(&mirrors);
|
||||
}
|
||||
|
||||
/// The app (background mode) never waits: with the model missing every request fails at once;
|
||||
/// with a damaged model the requests are queued, fail on the worker, and come back as messages.
|
||||
#[test]
|
||||
fn background_requests_never_wait_and_failures_come_back_as_messages() {
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.background = true;
|
||||
let id = s.active().unwrap();
|
||||
s.segmenter.dir = Some(tmp("missing"));
|
||||
for (cmd, p) in [("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
|
||||
{
|
||||
let t = Instant::now();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(t.elapsed() < Duration::from_millis(500), "{cmd}");
|
||||
if Segmenter::AVAILABLE {
|
||||
assert!(e.starts_with(NOT_INSTALLED), "{cmd}: {e}");
|
||||
} else {
|
||||
assert!(e.contains("not available"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(s.develop_of(id).unwrap().masks.is_empty());
|
||||
if !Segmenter::AVAILABLE {
|
||||
return;
|
||||
}
|
||||
// a model folder whose weights are damaged
|
||||
let dir = damaged_model("damaged");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
assert!(s.segmenter.installed());
|
||||
let t = Instant::now();
|
||||
s.execute("mask.add", &json!({"kind": "object"})).unwrap();
|
||||
let r = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(s.segmenter.pending_clicks().is_some_and(|p| p.hint.len() == 1));
|
||||
// a second click while the first is on its way builds on it
|
||||
let r = s.execute("mask.objectPoint", &json!({"x": 0.6, "y": 0.5, "exclude": true})).unwrap();
|
||||
assert_eq!((r["include"].as_u64(), r["exclude"].as_u64()), (Some(1), Some(1)));
|
||||
let r = s.execute("mask.add", &json!({"kind": "prompt", "text": "the sky"})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(t.elapsed() < Duration::from_secs(1), "nothing waited for the model: {:?}", t.elapsed());
|
||||
let messages = settle(&mut s, Duration::from_secs(60));
|
||||
assert!(!messages.is_empty() && messages.iter().all(|m| !m.is_empty()), "{messages:?}");
|
||||
assert!(messages.iter().any(|m| m.contains("model.safetensors") || m.contains("SAM 3")), "{messages:?}");
|
||||
assert!(s.segmenter.pending_clicks().is_none());
|
||||
// the Object mask is there, without a selection; no Describe mask was made from nothing
|
||||
let d = s.develop_of(id).unwrap();
|
||||
assert_eq!(d.masks.len(), 1);
|
||||
assert!(matches!(&d.masks[0].components[0].shape, lightcraft_develop::MaskShape::Object { seg: None, .. }));
|
||||
assert!(!s.segmenter.busy() && !s.segmenter.loaded());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// CLI / MCP (waiting mode): a damaged model is an error from the command, quickly.
|
||||
#[test]
|
||||
fn waiting_requests_with_a_damaged_model_are_errors() {
|
||||
if !Segmenter::AVAILABLE {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let dir = damaged_model("damaged-wait");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
|
||||
assert!(e.contains("model.safetensors") || e.contains("SAM 3"), "{e}");
|
||||
let e = s.execute("mask.add", &json!({"kind": "object", "points": [[0.5, 0.5]]})).unwrap_err().to_string();
|
||||
assert!(!e.is_empty());
|
||||
assert!(s.develop_of(id).unwrap().masks.is_empty());
|
||||
assert!(!s.segmenter.busy());
|
||||
// a stored segmentation needs no model at all (replayed or pasted masks)
|
||||
let seg = serde_json::to_value(lightcraft_develop::SegMask::from_logits(4, &[5.0; 16])).unwrap();
|
||||
s.segmenter.dir = None;
|
||||
s.execute("mask.add", &json!({"kind": "prompt", "text": "sky", "seg": seg})).unwrap();
|
||||
assert_eq!(s.develop_of(id).unwrap().masks.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Too many clicks on one Object selection are refused, not sent to the model.
|
||||
#[test]
|
||||
fn object_clicks_are_capped() {
|
||||
if !Segmenter::AVAILABLE {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.background = true;
|
||||
let dir = damaged_model("clicks");
|
||||
s.segmenter.dir = Some(dir.clone());
|
||||
let id = s.active().unwrap();
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
let many = vec![lightcraft_geom::Point::new(0.5, 0.5); crate::segment::MAX_CLICKS];
|
||||
d.masks.push(lightcraft_develop::Mask {
|
||||
id: 1,
|
||||
components: vec![lightcraft_develop::MaskComponent {
|
||||
name: None,
|
||||
op: lightcraft_develop::MaskOp::Add,
|
||||
invert: false,
|
||||
shape: lightcraft_develop::MaskShape::Object { hint: many, exclude: vec![], seg: None, detail: vec![], edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
s.set_develop(id, d, "test").unwrap();
|
||||
s.active_mask = Some(1);
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
|
||||
assert!(e.contains("at most"), "{e}");
|
||||
assert!(!s.segmenter.busy());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -173,6 +173,162 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
const MWG_REGIONS: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
|
||||
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
|
||||
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
|
||||
<mwg-rs:Regions>
|
||||
<rdf:Description>
|
||||
<mwg-rs:AppliedToDimensions stDim:w="100" stDim:h="100" stDim:unit="pixel"/>
|
||||
<mwg-rs:RegionList>
|
||||
<rdf:Bag>
|
||||
<rdf:li>
|
||||
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
|
||||
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
|
||||
</rdf:Description>
|
||||
</rdf:li>
|
||||
</rdf:Bag>
|
||||
</mwg-rs:RegionList>
|
||||
</rdf:Description>
|
||||
</mwg-rs:Regions>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>"#;
|
||||
|
||||
/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on
|
||||
/// import — the same path real catalogs use, exercised end to end rather than just at the parser.
|
||||
#[test]
|
||||
fn regions_from_sidecar_are_read_on_import() {
|
||||
let src = temp_dir("regions");
|
||||
write_png(&src.join("portrait.png"), 9);
|
||||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["sidecars"], 1);
|
||||
let p = s.catalog.photos().next().unwrap().clone();
|
||||
assert_eq!(p.meta.regions.len(), 1);
|
||||
let region = &p.meta.regions[0];
|
||||
assert_eq!(region.name.as_deref(), Some("Jane Doe"));
|
||||
assert_eq!(region.kind, lightcraft_meta::RegionKind::Face);
|
||||
assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's
|
||||
/// regions and everything else in it stay byte for byte), even with auto-write on.
|
||||
#[test]
|
||||
fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() {
|
||||
let src = temp_dir("region-remove");
|
||||
write_png(&src.join("portrait.png"), 9);
|
||||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
|
||||
let n = s.catalog.photo(id).unwrap().meta.regions.len();
|
||||
assert!(n >= 1);
|
||||
assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range");
|
||||
assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index");
|
||||
let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(r["removed"], "Jane Doe");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1);
|
||||
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
|
||||
// an ordinary edit still auto-writes (the guard is per command)
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits");
|
||||
// undo brings the region back
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the
|
||||
/// sidecar alone like removal does.
|
||||
#[test]
|
||||
fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() {
|
||||
let src = temp_dir("region-resize");
|
||||
write_png(&src.join("portrait.png"), 9);
|
||||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
|
||||
let original = s.catalog.photo(id).unwrap().meta.regions[0].rect;
|
||||
let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect}));
|
||||
// hostile input is an error and changes nothing
|
||||
for bad in [
|
||||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}),
|
||||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}),
|
||||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}),
|
||||
json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}),
|
||||
json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}),
|
||||
] {
|
||||
assert!(set(&mut s, bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region");
|
||||
assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
|
||||
// corners given in any order and outside the photo are clamped into it
|
||||
let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap();
|
||||
assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0}));
|
||||
let now = s.catalog.photo(id).unwrap().meta.regions[0].rect;
|
||||
assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0));
|
||||
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Re-reading a sidecar: one that has `mwg-rs:Regions` is authoritative, so an emptied region list
|
||||
/// clears the stale regions; one without `mwg-rs:Regions` (an app that doesn't do regions) leaves the
|
||||
/// photo's regions alone.
|
||||
#[test]
|
||||
fn rereading_a_sidecar_clears_regions_only_when_it_states_them() {
|
||||
let src = temp_dir("region-reread");
|
||||
write_png(&src.join("portrait.png"), 9);
|
||||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
|
||||
// another app rewrote the sidecar without any notion of regions: they stay
|
||||
std::fs::write(
|
||||
src.join("portrait.xmp"),
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/>
|
||||
</rdf:RDF></x:xmpmeta>"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.rating, 3, "the sidecar was read");
|
||||
assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions");
|
||||
// a region-aware app removed every region: the sidecar says so, and the catalog follows
|
||||
let emptied = MWG_REGIONS.replace(
|
||||
r#"<rdf:li>
|
||||
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
|
||||
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
|
||||
</rdf:Description>
|
||||
</rdf:li>"#,
|
||||
"",
|
||||
);
|
||||
assert_ne!(emptied, MWG_REGIONS);
|
||||
std::fs::write(src.join("portrait.xmp"), emptied).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions");
|
||||
// undo brings them back
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
||||
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
|
||||
}
|
||||
|
||||
@@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
if let Some(e) = f.edited {
|
||||
add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null}));
|
||||
}
|
||||
for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] {
|
||||
for (name, key, val) in
|
||||
[("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword), ("Person", "person", &f.person)]
|
||||
{
|
||||
if let Some(x) = val {
|
||||
add(format!("{name}: {x}"), json!({key: Null}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "lightcraft-fetch"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
description = "LightCraft model downloads: mirrors, resume, pinned SHA-256, over HTTPS in pure Rust (L0)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# downloads run natively only (the web build fetches no models)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
log = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] }
|
||||
rustls-rustcrypto = "0.0.2-alpha"
|
||||
webpki-roots = "1"
|
||||
sha2 = { version = "0.10", default-features = false, features = ["std"] }
|
||||
@@ -0,0 +1,541 @@
|
||||
//! A small HTTP/1.1 GET client for model downloads, in pure Rust: `std::net` sockets and, for
|
||||
//! `https://`, rustls with the RustCrypto provider (`rustls-rustcrypto`; no `ring` or
|
||||
//! `aws-lc-rs`, so nothing is compiled from C or assembly) and the Mozilla root certificates
|
||||
//! (`webpki-roots`).
|
||||
//!
|
||||
//! Only what a large-file download needs: one request per connection (`Connection: close`),
|
||||
//! `Range` requests for resuming, redirects (followed by the caller), `Content-Length`,
|
||||
//! chunked and read-to-close bodies. Every read waits at most `stall` for data and checks the
|
||||
//! cancel flag at least twice a second, so a dead server or a cancelled download never hangs
|
||||
//! the downloading thread. No proxies (see docs/ai-masks.md).
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Why a request failed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HttpError {
|
||||
/// The URL can't be used (scheme, host, characters).
|
||||
BadUrl(String),
|
||||
/// Name resolution or connecting failed (or took longer than the connect timeout).
|
||||
Connect(String),
|
||||
/// No data arrived for the stall timeout.
|
||||
Stalled,
|
||||
/// The cancel flag was raised.
|
||||
Cancelled,
|
||||
/// TLS failed (certificate, handshake).
|
||||
Tls(String),
|
||||
/// The server's response could not be understood.
|
||||
Protocol(String),
|
||||
/// Any other I/O error.
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpError::BadUrl(e) => write!(f, "unusable URL: {e}"),
|
||||
HttpError::Connect(e) => write!(f, "could not connect: {e}"),
|
||||
HttpError::Stalled => write!(f, "the server stopped sending data"),
|
||||
HttpError::Cancelled => write!(f, "cancelled"),
|
||||
HttpError::Tls(e) => write!(f, "secure connection failed: {e}"),
|
||||
HttpError::Protocol(e) => write!(f, "unexpected response: {e}"),
|
||||
HttpError::Io(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `http://` or `https://` URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Url {
|
||||
pub tls: bool,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Path and query, starting with `/`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl Url {
|
||||
pub fn parse(s: &str) -> Result<Url, HttpError> {
|
||||
let bad = |why: &str| HttpError::BadUrl(format!("{s}: {why}"));
|
||||
let s = s.trim();
|
||||
if s.chars().any(|c| c.is_control() || c == ' ') {
|
||||
return Err(bad("contains spaces or control characters"));
|
||||
}
|
||||
let (tls, rest) = if let Some(r) = s.strip_prefix("https://") {
|
||||
(true, r)
|
||||
} else if let Some(r) = s.strip_prefix("http://") {
|
||||
(false, r)
|
||||
} else {
|
||||
return Err(bad("only http:// and https:// are supported"));
|
||||
};
|
||||
let rest = rest.split('#').next().unwrap_or_default();
|
||||
let (authority, path) = match rest.find(['/', '?']) {
|
||||
Some(i) => (rest.get(..i).unwrap_or_default(), rest.get(i..).unwrap_or_default()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
|
||||
if authority.contains('@') {
|
||||
return Err(bad("user names and passwords in URLs are not supported"));
|
||||
}
|
||||
let (host, port) = if let Some(v6) = authority.strip_prefix('[') {
|
||||
let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?;
|
||||
let host = v6.get(..end).unwrap_or_default();
|
||||
let after = v6.get(end + 1..).unwrap_or_default();
|
||||
(host.to_string(), after.strip_prefix(':'))
|
||||
} else {
|
||||
match authority.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_string(), Some(p)),
|
||||
None => (authority.to_string(), None),
|
||||
}
|
||||
};
|
||||
let port = match port {
|
||||
Some(p) => p.parse::<u16>().ok().filter(|p| *p != 0).ok_or_else(|| bad("bad port"))?,
|
||||
None if tls => 443,
|
||||
None => 80,
|
||||
};
|
||||
if host.is_empty() || !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | ':' | '_')) {
|
||||
return Err(bad("bad host name"));
|
||||
}
|
||||
Ok(Url { tls, host: host.to_ascii_lowercase(), port, path })
|
||||
}
|
||||
|
||||
/// `Location` of a redirect, resolved against this URL.
|
||||
pub fn join(&self, location: &str) -> Result<Url, HttpError> {
|
||||
let l = location.trim();
|
||||
if l.starts_with("http://") || l.starts_with("https://") {
|
||||
return Url::parse(l);
|
||||
}
|
||||
// another scheme (`ftp:`, `file:`, `data:`…)
|
||||
if let Some((scheme, _)) = l.split_once(':')
|
||||
&& !scheme.is_empty()
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return Err(HttpError::BadUrl(format!("redirect to an unsupported location `{scheme}:`")));
|
||||
}
|
||||
let scheme = if self.tls { "https" } else { "http" };
|
||||
let host = if self.host.contains(':') { format!("[{}]", self.host) } else { self.host.clone() };
|
||||
let base = format!("{scheme}://{host}:{}", self.port);
|
||||
if let Some(rest) = l.strip_prefix("//") {
|
||||
return Url::parse(&format!("{scheme}://{rest}"));
|
||||
}
|
||||
if l.starts_with('/') {
|
||||
return Url::parse(&format!("{base}{l}"));
|
||||
}
|
||||
// relative to the current directory
|
||||
let dir = self.path.split('?').next().unwrap_or("/");
|
||||
let dir = dir.rsplit_once('/').map_or("", |(d, _)| d);
|
||||
Url::parse(&format!("{base}{dir}/{l}"))
|
||||
}
|
||||
|
||||
fn host_header(&self) -> String {
|
||||
let host = if self.host.contains(':') { format!("[{}]", self.host) } else { self.host.clone() };
|
||||
let default = if self.tls { 443 } else { 80 };
|
||||
if self.port == default { host } else { format!("{host}:{}", self.port) }
|
||||
}
|
||||
|
||||
/// The URL without its query (for messages: a query may hold a signed token).
|
||||
pub fn display(&self) -> String {
|
||||
let scheme = if self.tls { "https" } else { "http" };
|
||||
format!("{scheme}://{}{}", self.host_header(), self.path.split('?').next().unwrap_or("/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeouts and the cancel flag for one request.
|
||||
pub struct Limits<'a> {
|
||||
pub connect: Duration,
|
||||
pub stall: Duration,
|
||||
pub cancel: &'a AtomicBool,
|
||||
}
|
||||
|
||||
/// How often a blocked read wakes up to check the cancel flag.
|
||||
const TICK: Duration = Duration::from_millis(250);
|
||||
/// Longest status line + headers accepted.
|
||||
const MAX_HEAD: usize = 64 * 1024;
|
||||
|
||||
enum Stream {
|
||||
Plain(TcpStream),
|
||||
Tls(Box<rustls::ClientConnection>, TcpStream),
|
||||
}
|
||||
|
||||
fn is_timeout(e: &std::io::Error) -> bool {
|
||||
matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted)
|
||||
}
|
||||
|
||||
fn tls_config() -> Result<Arc<rustls::ClientConfig>, HttpError> {
|
||||
let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
|
||||
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| HttpError::Tls(e.to_string()))?
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn connect(url: &Url, limits: &Limits) -> Result<Stream, HttpError> {
|
||||
let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect();
|
||||
if addrs.is_empty() {
|
||||
return Err(HttpError::Connect(format!("{}: no address", url.host)));
|
||||
}
|
||||
let mut last = String::new();
|
||||
let mut sock = None;
|
||||
for a in addrs {
|
||||
if limits.cancel.load(Ordering::Relaxed) {
|
||||
return Err(HttpError::Cancelled);
|
||||
}
|
||||
match TcpStream::connect_timeout(&a, limits.connect) {
|
||||
Ok(s) => {
|
||||
sock = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = format!("{}: {e}", url.host),
|
||||
}
|
||||
}
|
||||
let sock = sock.ok_or(HttpError::Connect(last))?;
|
||||
sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
|
||||
sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
|
||||
let _ = sock.set_nodelay(true);
|
||||
if !url.tls {
|
||||
return Ok(Stream::Plain(sock));
|
||||
}
|
||||
let name = rustls::pki_types::ServerName::try_from(url.host.clone()).map_err(|e| HttpError::Tls(format!("{}: {e}", url.host)))?;
|
||||
let conn = rustls::ClientConnection::new(tls_config()?, name).map_err(|e| HttpError::Tls(e.to_string()))?;
|
||||
let mut s = Stream::Tls(Box::new(conn), sock);
|
||||
s.handshake(limits)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Wait for `f` to make progress, retrying timeouts until `stall` passes without any.
|
||||
fn retry<T>(limits: &Limits, mut f: impl FnMut() -> std::io::Result<T>) -> Result<T, HttpError> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if limits.cancel.load(Ordering::Relaxed) {
|
||||
return Err(HttpError::Cancelled);
|
||||
}
|
||||
match f() {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) if is_timeout(&e) => {
|
||||
if start.elapsed() >= limits.stall {
|
||||
return Err(HttpError::Stalled);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::InvalidData => return Err(HttpError::Tls(e.to_string())),
|
||||
Err(e) => return Err(HttpError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handshake(&mut self, limits: &Limits) -> Result<(), HttpError> {
|
||||
let Stream::Tls(conn, sock) = self else { return Ok(()) };
|
||||
while conn.is_handshaking() {
|
||||
Self::retry(limits, || conn.complete_io(sock))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_all(&mut self, data: &[u8], limits: &Limits) -> Result<(), HttpError> {
|
||||
match self {
|
||||
Stream::Plain(sock) => {
|
||||
let mut rest = data;
|
||||
while !rest.is_empty() {
|
||||
let n = Self::retry(limits, || sock.write(rest))?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Io("connection closed while sending the request".into()));
|
||||
}
|
||||
rest = rest.get(n..).unwrap_or_default();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Stream::Tls(conn, sock) => {
|
||||
// buffered by rustls, then sent as TLS records
|
||||
conn.writer().write_all(data).map_err(|e| HttpError::Tls(e.to_string()))?;
|
||||
while conn.wants_write() {
|
||||
Self::retry(limits, || conn.write_tls(sock))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read some bytes (0 at the end of the stream), waiting at most `stall` for them.
|
||||
fn read(&mut self, buf: &mut [u8], limits: &Limits) -> Result<usize, HttpError> {
|
||||
match self {
|
||||
Stream::Plain(sock) => Self::retry(limits, || sock.read(buf)),
|
||||
Stream::Tls(conn, sock) => {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if limits.cancel.load(Ordering::Relaxed) {
|
||||
return Err(HttpError::Cancelled);
|
||||
}
|
||||
match conn.reader().read(buf) {
|
||||
Ok(n) => return Ok(n),
|
||||
// closed without close_notify: the caller checks the length it got
|
||||
Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(0),
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(HttpError::Tls(e.to_string())),
|
||||
}
|
||||
// no plaintext buffered: more TLS records from the socket
|
||||
match conn.read_tls(sock) {
|
||||
Ok(0) => return Ok(0),
|
||||
Ok(_) => {
|
||||
conn.process_new_packets().map_err(|e| HttpError::Tls(e.to_string()))?;
|
||||
while conn.wants_write() {
|
||||
Self::retry(limits, || conn.write_tls(sock))?;
|
||||
}
|
||||
}
|
||||
Err(e) if is_timeout(&e) => {
|
||||
if start.elapsed() >= limits.stall {
|
||||
return Err(HttpError::Stalled);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(HttpError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection with a read buffer (headers and chunk sizes are read line by line).
|
||||
struct Conn {
|
||||
stream: Stream,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
fn fill(&mut self, limits: &Limits) -> Result<usize, HttpError> {
|
||||
if self.pos >= self.buf.len() {
|
||||
self.buf.clear();
|
||||
self.pos = 0;
|
||||
}
|
||||
let mut tmp = [0u8; 64 * 1024];
|
||||
let n = self.stream.read(&mut tmp, limits)?;
|
||||
self.buf.extend_from_slice(tmp.get(..n).unwrap_or_default());
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// One line without its CRLF; at most `max` bytes.
|
||||
fn line(&mut self, max: usize, limits: &Limits) -> Result<String, HttpError> {
|
||||
loop {
|
||||
let avail = self.buf.get(self.pos..).unwrap_or_default();
|
||||
if let Some(i) = avail.iter().position(|b| *b == b'\n') {
|
||||
let line = avail.get(..i).unwrap_or_default();
|
||||
let line = line.strip_suffix(b"\r").unwrap_or(line);
|
||||
let s = String::from_utf8_lossy(line).into_owned();
|
||||
self.pos = self.pos.saturating_add(i + 1);
|
||||
return Ok(s);
|
||||
}
|
||||
if avail.len() > max {
|
||||
return Err(HttpError::Protocol("header line too long".into()));
|
||||
}
|
||||
// keep the partial line, read more after it
|
||||
if self.pos > 0 {
|
||||
self.buf.drain(..self.pos.min(self.buf.len()));
|
||||
self.pos = 0;
|
||||
}
|
||||
let mut tmp = [0u8; 16 * 1024];
|
||||
let n = self.stream.read(&mut tmp, limits)?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Protocol("connection closed in the middle of the response".into()));
|
||||
}
|
||||
self.buf.extend_from_slice(tmp.get(..n).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to `out.len()` body bytes (0 at the end of the stream).
|
||||
fn read(&mut self, out: &mut [u8], limits: &Limits) -> Result<usize, HttpError> {
|
||||
if self.pos >= self.buf.len() && self.fill(limits)? == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let avail = self.buf.get(self.pos..).unwrap_or_default();
|
||||
let n = avail.len().min(out.len());
|
||||
if let (Some(dst), Some(src)) = (out.get_mut(..n), avail.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
self.pos = self.pos.saturating_add(n);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// A response's status, headers and body.
|
||||
pub struct Response {
|
||||
pub status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
conn: Conn,
|
||||
body: BodyMode,
|
||||
}
|
||||
|
||||
enum BodyMode {
|
||||
Length(u64),
|
||||
Chunked { left: u64, done: bool },
|
||||
Close,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// The first header named `name` (case-insensitive).
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// The body's length when the server announced it.
|
||||
pub fn content_length(&self) -> Option<u64> {
|
||||
match self.body {
|
||||
BodyMode::Length(n) => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read body bytes into `out` (0 at the end of the body).
|
||||
pub fn read(&mut self, out: &mut [u8], limits: &Limits) -> Result<usize, HttpError> {
|
||||
match &mut self.body {
|
||||
BodyMode::Done => Ok(0),
|
||||
BodyMode::Length(left) => {
|
||||
if *left == 0 {
|
||||
self.body = BodyMode::Done;
|
||||
return Ok(0);
|
||||
}
|
||||
let want = usize::try_from(*left).unwrap_or(usize::MAX).min(out.len());
|
||||
let n = self.conn.read(out.get_mut(..want).unwrap_or_default(), limits)?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Protocol("the connection closed before the end of the file".into()));
|
||||
}
|
||||
*left = left.saturating_sub(n as u64);
|
||||
Ok(n)
|
||||
}
|
||||
BodyMode::Close => {
|
||||
let n = self.conn.read(out, limits)?;
|
||||
if n == 0 {
|
||||
self.body = BodyMode::Done;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
BodyMode::Chunked { left, done } => {
|
||||
if *done {
|
||||
return Ok(0);
|
||||
}
|
||||
if *left == 0 {
|
||||
let line = self.conn.line(1024, limits)?;
|
||||
let hex = line.split(';').next().unwrap_or_default().trim();
|
||||
let size = u64::from_str_radix(hex, 16).map_err(|_| HttpError::Protocol(format!("bad chunk size `{hex}`")))?;
|
||||
if size == 0 {
|
||||
// trailers, up to the empty line
|
||||
let mut n = 0;
|
||||
while !self.conn.line(MAX_HEAD, limits)?.is_empty() {
|
||||
n += 1;
|
||||
if n > 100 {
|
||||
return Err(HttpError::Protocol("too many trailers".into()));
|
||||
}
|
||||
}
|
||||
*done = true;
|
||||
return Ok(0);
|
||||
}
|
||||
*left = size;
|
||||
}
|
||||
let want = usize::try_from(*left).unwrap_or(usize::MAX).min(out.len());
|
||||
let n = self.conn.read(out.get_mut(..want).unwrap_or_default(), limits)?;
|
||||
if n == 0 {
|
||||
return Err(HttpError::Protocol("the connection closed in the middle of a chunk".into()));
|
||||
}
|
||||
*left = left.saturating_sub(n as u64);
|
||||
if *left == 0 && !self.conn.line(16, limits)?.is_empty() {
|
||||
return Err(HttpError::Protocol("missing CRLF after a chunk".into()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `GET url` with extra `headers` and read the status line and headers.
|
||||
pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Response, HttpError> {
|
||||
let mut req = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: LightCraft/{}\r\nAccept: */*\r\nAccept-Encoding: identity\r\nConnection: close\r\n",
|
||||
url.path,
|
||||
url.host_header(),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
for (k, v) in headers {
|
||||
if v.chars().any(|c| c == '\r' || c == '\n') || k.chars().any(|c| c == '\r' || c == '\n' || c == ':') {
|
||||
return Err(HttpError::BadUrl("header with a line break".into()));
|
||||
}
|
||||
req.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
let mut stream = Stream::connect(url, limits)?;
|
||||
stream.write_all(req.as_bytes(), limits)?;
|
||||
let mut conn = Conn { stream, buf: Vec::new(), pos: 0 };
|
||||
let status_line = conn.line(MAX_HEAD, limits)?;
|
||||
let mut parts = status_line.split_whitespace();
|
||||
let version = parts.next().unwrap_or_default();
|
||||
if !version.starts_with("HTTP/1.") {
|
||||
return Err(HttpError::Protocol(format!("not an HTTP/1.x response: {}", status_line.chars().take(60).collect::<String>())));
|
||||
}
|
||||
let status = parts.next().and_then(|s| s.parse::<u16>().ok()).ok_or_else(|| HttpError::Protocol("bad status line".into()))?;
|
||||
let mut headers = Vec::new();
|
||||
let mut total = status_line.len();
|
||||
loop {
|
||||
let line = conn.line(MAX_HEAD, limits)?;
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
total = total.saturating_add(line.len());
|
||||
if total > MAX_HEAD || headers.len() > 200 {
|
||||
return Err(HttpError::Protocol("response headers too large".into()));
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
headers.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
let find = |name: &str| headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str());
|
||||
let body = if status == 204 || status == 304 || (100..200).contains(&status) {
|
||||
BodyMode::Done
|
||||
} else if find("transfer-encoding").is_some_and(|v| v.to_ascii_lowercase().contains("chunked")) {
|
||||
BodyMode::Chunked { left: 0, done: false }
|
||||
} else if let Some(n) = find("content-length") {
|
||||
BodyMode::Length(n.trim().parse::<u64>().map_err(|_| HttpError::Protocol(format!("bad Content-Length `{n}`")))?)
|
||||
} else {
|
||||
BodyMode::Close
|
||||
};
|
||||
Ok(Response { status, headers, conn, body })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_urls() {
|
||||
let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap();
|
||||
assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "cdn.example.com", 443, "/models/sam3/model.safetensors?sig=1"));
|
||||
assert_eq!(u.display(), "https://cdn.example.com/models/sam3/model.safetensors");
|
||||
let u = Url::parse("http://127.0.0.1:8080").unwrap();
|
||||
assert_eq!((u.tls, u.port, u.path.as_str()), (false, 8080, "/"));
|
||||
let u = Url::parse("http://[::1]:9/a").unwrap();
|
||||
assert_eq!((u.host.as_str(), u.port), ("::1", 9));
|
||||
for bad in ["ftp://x/y", "https://", "https://a b/", "https://u:p@h/", "http://h:0/", "http://h:99999/", "https://h/\r\nX: y", "file:///etc"]
|
||||
{
|
||||
assert!(Url::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_redirects() {
|
||||
let u = Url::parse("https://a.example/x/y/file?q").unwrap();
|
||||
assert_eq!(u.join("https://b.example/z").unwrap().host, "b.example");
|
||||
assert_eq!(u.join("/root").unwrap().path, "/root");
|
||||
assert_eq!(u.join("other").unwrap().path, "/x/y/other");
|
||||
assert_eq!(u.join("//c.example/p").unwrap().host, "c.example");
|
||||
assert!(u.join("ftp://nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tls_config_builds_with_the_pure_rust_provider() {
|
||||
assert!(tls_config().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Downloading a model's files, only when the user asked for it: from an ordered list of mirrors
|
||||
//! (the next one when a mirror fails), resuming partial files, with connect and stall timeouts,
|
||||
//! a size cap, progress and cancellation, and checked (exact size, SHA-256 where pinned) before
|
||||
//! each file is moved into the model folder. Runs on whatever thread calls [`download`] (the
|
||||
//! engine uses a background thread; the UI thread never waits for the network). Pure Rust: the
|
||||
//! HTTPS client is rustls with the RustCrypto provider (see [`http`]).
|
||||
//!
|
||||
//! Files are written to `<name>.part` next to their final place and renamed when verified, so a
|
||||
//! model folder never holds a half-written file under a real name, and an interrupted download
|
||||
//! resumes from where it stopped. A file that fails its check is deleted, never kept.
|
||||
//!
|
||||
//! What to download and where from belongs to the caller: it passes the [`FileSpec`]s and the
|
||||
//! default mirrors (see [`mirrors`]). `lightcraft-segment` does so for the SAM 3 weights, which
|
||||
//! are never part of LightCraft (SAM License, see docs/ai-masks.md).
|
||||
//!
|
||||
//! Native only: on wasm32 this crate is empty (the web build downloads no models).
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod http;
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use http::{HttpError, Limits, Url};
|
||||
|
||||
/// One file of the model.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FileSpec {
|
||||
pub name: &'static str,
|
||||
/// Exact size in bytes, when pinned (then the download resumes and is checked against it).
|
||||
pub size: Option<u64>,
|
||||
/// SHA-256 (lower-case hex), when pinned.
|
||||
pub sha256: Option<&'static str>,
|
||||
/// Largest size accepted when `size` is not pinned.
|
||||
pub max: u64,
|
||||
}
|
||||
|
||||
/// Most mirrors used (a hostile list can't make a download loop forever).
|
||||
const MAX_MIRRORS: usize = 16;
|
||||
/// Redirects followed per request.
|
||||
const MAX_REDIRECTS: usize = 8;
|
||||
|
||||
/// The mirrors to try, in order: `env`'s (base URLs separated by commas, spaces or newlines), then
|
||||
/// the mirrors file's (one base URL per line, `#` comments), then `defaults`; duplicates and
|
||||
/// unusable URLs dropped, at most 16.
|
||||
pub fn mirrors(env: Option<&str>, file: Option<&Path>, defaults: &[&str]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |s: &str| {
|
||||
let s = s.trim().trim_end_matches('/');
|
||||
if !s.is_empty() && Url::parse(s).is_ok() && !out.iter().any(|m| m == s) && out.len() < MAX_MIRRORS {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
if let Some(env) = env {
|
||||
env.split([',', ' ', '\n', '\t']).for_each(&mut add);
|
||||
}
|
||||
// a small text file; anything larger isn't a mirror list
|
||||
if let Some(text) = file.and_then(|f| std::fs::metadata(f).ok().filter(|m| m.len() < 64 * 1024).and_then(|_| std::fs::read_to_string(f).ok())) {
|
||||
text.lines().map(|l| l.split('#').next().unwrap_or_default()).for_each(&mut add);
|
||||
}
|
||||
defaults.iter().for_each(|m| add(m));
|
||||
out
|
||||
}
|
||||
|
||||
/// Download settings.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Options {
|
||||
/// Longest wait to connect to a mirror.
|
||||
pub connect_timeout: Duration,
|
||||
/// Longest wait for the next bytes once connected.
|
||||
pub stall_timeout: Duration,
|
||||
/// Tries per mirror and file (each resumes where the last one stopped).
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a download is.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
pub file: String,
|
||||
/// Bytes of all files on disk so far, and the total expected (pinned sizes + announced ones).
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
/// The mirror in use (without query).
|
||||
pub mirror: String,
|
||||
}
|
||||
|
||||
/// Why a download failed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DownloadError {
|
||||
/// No mirror is configured.
|
||||
NoMirrors,
|
||||
Cancelled,
|
||||
/// A local file problem (folder not writable, disk full…).
|
||||
Disk(String),
|
||||
/// Every mirror failed for `file`; the reason for each.
|
||||
AllMirrorsFailed {
|
||||
file: String,
|
||||
errors: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DownloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DownloadError::NoMirrors => write!(f, "no download location is configured for this model in this build"),
|
||||
DownloadError::Cancelled => write!(f, "the download was cancelled"),
|
||||
DownloadError::Disk(e) => write!(f, "{e}"),
|
||||
DownloadError::AllMirrorsFailed { file, errors } => write!(f, "could not download {file}: {}", errors.join("; ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One mirror's failure for one file; `Retry` resumes on the same mirror.
|
||||
enum Fail {
|
||||
Retry(String),
|
||||
NextMirror(String),
|
||||
Cancelled,
|
||||
Disk(String),
|
||||
}
|
||||
|
||||
/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Files already there
|
||||
/// and verified are kept. `progress` is called as bytes arrive (often: throttle in it).
|
||||
pub fn download(
|
||||
files: &[FileSpec],
|
||||
mirrors: &[String],
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
) -> Result<(), DownloadError> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| DownloadError::Disk(format!("can't create {}: {e}", dir.display())))?;
|
||||
// what's left to fetch
|
||||
let mut todo = Vec::new();
|
||||
for f in files {
|
||||
if !verified(f, &dir.join(f.name))? {
|
||||
todo.push(f);
|
||||
}
|
||||
}
|
||||
if todo.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if mirrors.is_empty() {
|
||||
return Err(DownloadError::NoMirrors);
|
||||
}
|
||||
let total: u64 = files.iter().map(|f| f.size.unwrap_or(0)).sum();
|
||||
let mut before: u64 = files.iter().filter(|f| !todo.contains(f)).filter_map(|f| f.size).sum();
|
||||
for f in todo {
|
||||
let mut errors = Vec::new();
|
||||
let mut ok = false;
|
||||
'mirrors: for m in mirrors {
|
||||
let url = format!("{m}/{}", f.name);
|
||||
for _ in 0..opts.attempts.max(1) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DownloadError::Cancelled);
|
||||
}
|
||||
let mut report = |n: u64, announced: u64| {
|
||||
let mirror = Url::parse(m).map(|u| u.display()).unwrap_or_default();
|
||||
progress(&Progress {
|
||||
file: f.name.to_string(),
|
||||
done: before.saturating_add(n),
|
||||
total: total.max(before.saturating_add(announced)),
|
||||
mirror,
|
||||
})
|
||||
};
|
||||
match fetch_file(f, &url, dir, opts, cancel, &mut report) {
|
||||
Ok(()) => {
|
||||
ok = true;
|
||||
break 'mirrors;
|
||||
}
|
||||
Err(Fail::Cancelled) => return Err(DownloadError::Cancelled),
|
||||
Err(Fail::Disk(e)) => return Err(DownloadError::Disk(e)),
|
||||
Err(Fail::Retry(e)) => {
|
||||
log::warn!("download of {url}: {e} (retrying)");
|
||||
errors.push(format!("{}: {e}", short(m)));
|
||||
}
|
||||
Err(Fail::NextMirror(e)) => {
|
||||
log::warn!("download of {url}: {e}");
|
||||
errors.push(format!("{}: {e}", short(m)));
|
||||
continue 'mirrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return Err(DownloadError::AllMirrorsFailed { file: f.name.to_string(), errors });
|
||||
}
|
||||
before = before.saturating_add(std::fs::metadata(dir.join(f.name)).map(|m| m.len()).unwrap_or(0));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A mirror for messages (no query string: it may hold a token).
|
||||
fn short(m: &str) -> String {
|
||||
Url::parse(m).map(|u| u.display()).unwrap_or_else(|_| "mirror".into())
|
||||
}
|
||||
|
||||
/// Whether `path` is already the right file (exact size and hash when pinned).
|
||||
fn verified(f: &FileSpec, path: &Path) -> Result<bool, DownloadError> {
|
||||
let Ok(meta) = std::fs::metadata(path) else { return Ok(false) };
|
||||
if !meta.is_file() || meta.len() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if f.size.is_some_and(|s| s != meta.len()) || meta.len() > f.max {
|
||||
return Ok(false);
|
||||
}
|
||||
match f.sha256 {
|
||||
Some(want) => Ok(sha256_file(path).map_err(DownloadError::Disk)? == want),
|
||||
None => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(buf.get(..n).unwrap_or_default());
|
||||
}
|
||||
Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
fn part_path(dir: &Path, name: &str) -> PathBuf {
|
||||
dir.join(format!("{name}.part"))
|
||||
}
|
||||
|
||||
/// Fetch one file from one mirror into `<name>.part` (resuming it when its size is pinned),
|
||||
/// check it and move it into place.
|
||||
fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> {
|
||||
let part = part_path(dir, f.name);
|
||||
let limit = f.size.unwrap_or(f.max);
|
||||
let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display()));
|
||||
// resume only a pinned file (an unpinned one can't be checked after resuming)
|
||||
let mut have = match std::fs::metadata(&part) {
|
||||
Ok(m) if f.size.is_some() && m.len() <= limit => m.len(),
|
||||
Ok(_) => {
|
||||
std::fs::remove_file(&part).map_err(disk)?;
|
||||
0
|
||||
}
|
||||
Err(_) => 0,
|
||||
};
|
||||
let fetched = f.size != Some(have) || have == 0;
|
||||
if fetched {
|
||||
let limits = Limits { connect: opts.connect_timeout, stall: opts.stall_timeout, cancel };
|
||||
let mut url = Url::parse(url).map_err(|e| Fail::NextMirror(e.to_string()))?;
|
||||
let origin = url.host.clone();
|
||||
let mut redirects = 0;
|
||||
let mut resp = loop {
|
||||
let mut headers = vec![];
|
||||
if have > 0 {
|
||||
headers.push(("Range", format!("bytes={have}-")));
|
||||
}
|
||||
// a token for the configured host only (never sent on to a redirect's host)
|
||||
if let Ok(t) = std::env::var("LIGHTCRAFT_SAM3_TOKEN")
|
||||
&& !t.trim().is_empty()
|
||||
&& url.tls
|
||||
&& url.host == origin
|
||||
{
|
||||
headers.push(("Authorization", format!("Bearer {}", t.trim())));
|
||||
}
|
||||
let r = http::get(&url, &headers, &limits).map_err(http_fail)?;
|
||||
match r.status {
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
redirects += 1;
|
||||
if redirects > MAX_REDIRECTS {
|
||||
return Err(Fail::NextMirror("too many redirects".into()));
|
||||
}
|
||||
let loc = r.header("location").ok_or_else(|| Fail::NextMirror("redirect without a location".into()))?;
|
||||
url = url.join(loc).map_err(|e| Fail::NextMirror(e.to_string()))?;
|
||||
}
|
||||
_ => break r,
|
||||
}
|
||||
};
|
||||
let mut file = match resp.status {
|
||||
200 => {
|
||||
// the whole file (from the start, also when a range was asked for)
|
||||
if resp.content_length().is_some_and(|n| n > limit || f.size.is_some_and(|s| s != n)) {
|
||||
return Err(Fail::NextMirror(format!("the server's file has the wrong size ({:?} bytes)", resp.content_length())));
|
||||
}
|
||||
have = 0;
|
||||
File::create(&part).map_err(disk)?
|
||||
}
|
||||
206 if have > 0 => {
|
||||
let range = resp.header("content-range").unwrap_or_default();
|
||||
let (start, total) = parse_content_range(range).ok_or_else(|| Fail::NextMirror(format!("bad Content-Range `{range}`")))?;
|
||||
if start != have || total.is_some_and(|t| Some(t) != f.size) {
|
||||
return Err(Fail::NextMirror(format!("the server resumed at the wrong place ({range})")));
|
||||
}
|
||||
OpenOptions::new().append(true).open(&part).map_err(disk)?
|
||||
}
|
||||
416 => {
|
||||
// the partial file doesn't fit what the server has: start over
|
||||
std::fs::remove_file(&part).map_err(disk)?;
|
||||
return Err(Fail::Retry("the partial download didn't match; starting over".into()));
|
||||
}
|
||||
401 | 403 => return Err(Fail::NextMirror(format!("access denied (HTTP {})", resp.status))),
|
||||
404 | 410 => return Err(Fail::NextMirror(format!("not found (HTTP {})", resp.status))),
|
||||
s if s >= 500 => return Err(Fail::Retry(format!("server error (HTTP {s})"))),
|
||||
s => return Err(Fail::NextMirror(format!("HTTP {s}"))),
|
||||
};
|
||||
let announced = f.size.unwrap_or_else(|| resp.content_length().unwrap_or(0));
|
||||
report(have, announced);
|
||||
let mut buf = vec![0u8; 256 * 1024];
|
||||
loop {
|
||||
let n = match resp.read(&mut buf, &limits) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = file.flush();
|
||||
if f.size.is_none() {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
return Err(http_fail(e));
|
||||
}
|
||||
};
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if have.saturating_add(n as u64) > limit {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(Fail::NextMirror(format!("the server sent more than the expected {limit} bytes")));
|
||||
}
|
||||
file.write_all(buf.get(..n).unwrap_or_default()).map_err(disk)?;
|
||||
have = have.saturating_add(n as u64);
|
||||
report(have, announced);
|
||||
}
|
||||
file.sync_all().map_err(disk)?;
|
||||
}
|
||||
// checks: exact size, hash
|
||||
let len = std::fs::metadata(&part).map_err(disk)?.len();
|
||||
if f.size.is_some_and(|s| s != len) {
|
||||
return Err(Fail::Retry(format!("incomplete ({len} of {} bytes)", f.size.unwrap_or(0))));
|
||||
}
|
||||
if len == 0 {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(Fail::NextMirror("the server sent an empty file".into()));
|
||||
}
|
||||
if let Some(want) = f.sha256 {
|
||||
let got = sha256_file(&part).map_err(Fail::Disk)?;
|
||||
if got != want {
|
||||
std::fs::remove_file(&part).map_err(disk)?;
|
||||
let why = format!("the file is damaged or not the official one (SHA-256 {got})");
|
||||
// a damaged partial file from an earlier run isn't this mirror's fault
|
||||
return Err(if fetched { Fail::NextMirror(why) } else { Fail::Retry(why) });
|
||||
}
|
||||
}
|
||||
std::fs::rename(&part, dir.join(f.name)).map_err(disk)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_fail(e: HttpError) -> Fail {
|
||||
match e {
|
||||
HttpError::Cancelled => Fail::Cancelled,
|
||||
HttpError::Stalled | HttpError::Io(_) | HttpError::Protocol(_) => Fail::Retry(e.to_string()),
|
||||
HttpError::BadUrl(_) | HttpError::Connect(_) | HttpError::Tls(_) => Fail::NextMirror(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes a-b/total` → (a, total) (`*` total → None).
|
||||
fn parse_content_range(v: &str) -> Option<(u64, Option<u64>)> {
|
||||
let v = v.trim().strip_prefix("bytes")?.trim();
|
||||
let (range, total) = v.split_once('/')?;
|
||||
let (a, b) = range.split_once('-')?;
|
||||
let (a, b) = (a.trim().parse::<u64>().ok()?, b.trim().parse::<u64>().ok()?);
|
||||
if b < a {
|
||||
return None;
|
||||
}
|
||||
let total = match total.trim() {
|
||||
"*" => None,
|
||||
t => Some(t.parse::<u64>().ok()?),
|
||||
};
|
||||
Some((a, total))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,316 @@
|
||||
//! The downloader against a local HTTP server (std `TcpListener`; never the internet): mirror
|
||||
//! fallback, redirects, resuming, chunked bodies, stalls, hash mismatches, size caps, cancel.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The served file: 300 kB of a deterministic pattern.
|
||||
fn payload() -> Vec<u8> {
|
||||
(0..300_000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect()
|
||||
}
|
||||
|
||||
fn sha_hex(b: &[u8]) -> String {
|
||||
Sha256::digest(b).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn leak(s: String) -> &'static str {
|
||||
Box::leak(s.into_boxed_str())
|
||||
}
|
||||
|
||||
fn pinned() -> FileSpec {
|
||||
let p = payload();
|
||||
FileSpec { name: "model.bin", size: Some(p.len() as u64), sha256: Some(leak(sha_hex(&p))), max: p.len() as u64 }
|
||||
}
|
||||
|
||||
struct Server {
|
||||
base: String,
|
||||
hits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// Serve on 127.0.0.1; the first path segment picks the behaviour.
|
||||
fn server() -> Server {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", l.local_addr().unwrap());
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let h = hits.clone();
|
||||
std::thread::spawn(move || {
|
||||
for s in l.incoming().flatten() {
|
||||
let h = h.clone();
|
||||
std::thread::spawn(move || handle(s, &h));
|
||||
}
|
||||
});
|
||||
Server { base, hits }
|
||||
}
|
||||
|
||||
fn handle(mut s: TcpStream, hits: &AtomicUsize) {
|
||||
let n = hits.fetch_add(1, Ordering::SeqCst);
|
||||
let mut r = BufReader::new(s.try_clone().unwrap());
|
||||
let mut first = String::new();
|
||||
if r.read_line(&mut first).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut range: Option<u64> = None;
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
if r.read_line(&mut l).unwrap_or(0) == 0 || l.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = l.to_ascii_lowercase().strip_prefix("range: bytes=") {
|
||||
range = v.trim().trim_end_matches('-').parse().ok();
|
||||
}
|
||||
}
|
||||
let path = first.split_whitespace().nth(1).unwrap_or("/").to_string();
|
||||
let mode = path.trim_start_matches('/').split('/').next().unwrap_or("").to_string();
|
||||
let body = payload();
|
||||
let send = |s: &mut TcpStream, status: &str, headers: &str, data: &[u8]| {
|
||||
let _ = s.write_all(format!("HTTP/1.1 {status}\r\n{headers}Connection: close\r\n\r\n").as_bytes());
|
||||
let _ = s.write_all(data);
|
||||
};
|
||||
match mode.as_str() {
|
||||
"missing" => send(&mut s, "404 Not Found", "Content-Length: 9\r\n", b"not found"),
|
||||
"error" => send(&mut s, "500 Oops", "Content-Length: 25\r\n", b"<html>server error</html>"),
|
||||
"redirect" => send(&mut s, "302 Found", &format!("Location: {}\r\nContent-Length: 0\r\n", path.replacen("/redirect", "/good", 1)), b""),
|
||||
"wrong" => {
|
||||
let mut b = body.clone();
|
||||
b[1000] ^= 0xff;
|
||||
send(&mut s, "200 OK", &format!("Content-Length: {}\r\n", b.len()), &b)
|
||||
}
|
||||
"toolong" => {
|
||||
// no length announced, more bytes than the file can have
|
||||
let mut b = body.clone();
|
||||
b.extend_from_slice(&[7u8; 5000]);
|
||||
send(&mut s, "200 OK", "", &b)
|
||||
}
|
||||
"chunked" => {
|
||||
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n");
|
||||
for c in body.chunks(70_001) {
|
||||
let _ = s.write_all(format!("{:x};ext=1\r\n", c.len()).as_bytes());
|
||||
let _ = s.write_all(c);
|
||||
let _ = s.write_all(b"\r\n");
|
||||
}
|
||||
let _ = s.write_all(b"0\r\nX-Trailer: 1\r\n\r\n");
|
||||
}
|
||||
"stall" => {
|
||||
// half the file, then nothing
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
|
||||
let _ = s.write_all(&body[..body.len() / 2]);
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
"slow" => {
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
|
||||
for c in body.chunks(1000) {
|
||||
if s.write_all(c).is_err() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
"flaky" if n.is_multiple_of(2) && range.is_none() => {
|
||||
// the first try breaks off after a third
|
||||
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
|
||||
let _ = s.write_all(&body[..body.len() / 3]);
|
||||
}
|
||||
"range416" if range.is_some() => send(&mut s, "416 Range Not Satisfiable", "Content-Length: 0\r\n", b""),
|
||||
"norange" | "range416" => send(&mut s, "200 OK", &format!("Content-Length: {}\r\n", body.len()), &body),
|
||||
_ => match range {
|
||||
Some(from) if from as usize >= body.len() => send(&mut s, "416 Range Not Satisfiable", "Content-Length: 0\r\n", b""),
|
||||
Some(from) => send(
|
||||
&mut s,
|
||||
"206 Partial Content",
|
||||
&format!("Content-Range: bytes {from}-{}/{}\r\nContent-Length: {}\r\n", body.len() - 1, body.len(), body.len() - from as usize),
|
||||
&body[from as usize..],
|
||||
),
|
||||
None => send(&mut s, "200 OK", &format!("Content-Length: {}\r\n", body.len()), &body),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-sam3-fetch-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn quick() -> Options {
|
||||
Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2 }
|
||||
}
|
||||
|
||||
fn run(files: &[FileSpec], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec<Progress>) {
|
||||
let mut seen = Vec::new();
|
||||
let r = download(files, mirrors, dir, &quick(), cancel, &mut |p| seen.push(p.clone()));
|
||||
(r, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_next_mirror_and_verifies() {
|
||||
let srv = server();
|
||||
let dir = tmp("fallback");
|
||||
let mirrors = vec![
|
||||
format!("{}/missing", srv.base),
|
||||
format!("{}/error", srv.base),
|
||||
"http://127.0.0.1:1/unreachable".to_string(),
|
||||
format!("{}/redirect", srv.base),
|
||||
];
|
||||
let (r, seen) = run(&[pinned()], &mirrors, &dir, &AtomicBool::new(false));
|
||||
r.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
assert!(!part_path(&dir, "model.bin").exists());
|
||||
let last = seen.last().unwrap();
|
||||
assert_eq!((last.done, last.total), (300_000, 300_000));
|
||||
// an error page never ends up in the file: nothing left over from the failed mirrors
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
// installed: nothing is fetched again
|
||||
let hits = srv.hits.load(Ordering::SeqCst);
|
||||
run(&[pinned()], &mirrors, &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(srv.hits.load(Ordering::SeqCst), hits);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_mismatch_is_deleted_and_the_next_mirror_used() {
|
||||
let srv = server();
|
||||
let dir = tmp("hash");
|
||||
let (r, _) = run(&[pinned()], &[format!("{}/wrong", srv.base)], &dir, &AtomicBool::new(false));
|
||||
let e = r.unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
assert!(!dir.join("model.bin").exists() && !part_path(&dir, "model.bin").exists(), "a damaged file is never kept");
|
||||
run(&[pinned()], &[format!("{}/wrong", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_partial_files_and_restarts_bad_ones() {
|
||||
let srv = server();
|
||||
let dir = tmp("resume");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// a third already there
|
||||
std::fs::write(part_path(&dir, "model.bin"), &payload()[..100_000]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// a partial file with the wrong bytes: the hash fails, it's deleted, the next try starts over
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
std::fs::write(part_path(&dir, "model.bin"), vec![0u8; 100_000]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/good", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// a server ignoring Range: the whole file again, not appended
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
std::fs::write(part_path(&dir, "model.bin"), &payload()[..5]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/norange", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// a connection breaking off is resumed on the same mirror
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
run(&[pinned()], &[format!("{}/flaky", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_416_or_a_bad_complete_part_starts_over() {
|
||||
let srv = server();
|
||||
let dir = tmp("416");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// the server refuses the range: the part is dropped and the file fetched whole (no loop)
|
||||
std::fs::write(part_path(&dir, "model.bin"), &payload()[..100]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/range416", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
// as long as the file but wrong: hash fails → deleted → downloaded again from the same mirror
|
||||
std::fs::remove_file(dir.join("model.bin")).unwrap();
|
||||
std::fs::write(part_path(&dir, "model.bin"), vec![1u8; 300_000]).unwrap();
|
||||
run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_bodies_and_unpinned_files() {
|
||||
let srv = server();
|
||||
let dir = tmp("chunked");
|
||||
let f = FileSpec { name: "vocab.json", size: None, sha256: None, max: 1 << 20 };
|
||||
run(std::slice::from_ref(&f), &[format!("{}/chunked", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("vocab.json")).unwrap(), payload());
|
||||
// more than the cap: refused, nothing kept
|
||||
let small = FileSpec { max: 1000, ..f };
|
||||
let dir2 = tmp("cap");
|
||||
let e = run(&[small], &[format!("{}/good", srv.base)], &dir2, &AtomicBool::new(false)).0.unwrap_err().to_string();
|
||||
assert!(e.contains("wrong size") || e.contains("more than"), "{e}");
|
||||
assert!(!dir2.join("vocab.json").exists() && !part_path(&dir2, "vocab.json").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&dir2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_sending_too_much_is_cut_off() {
|
||||
let srv = server();
|
||||
let dir = tmp("toolong");
|
||||
let e = run(&[pinned()], &[format!("{}/toolong", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap_err().to_string();
|
||||
assert!(e.contains("more than"), "{e}");
|
||||
assert!(!dir.join("model.bin").exists() && !part_path(&dir, "model.bin").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stalled_server_times_out_and_the_next_mirror_finishes() {
|
||||
let srv = server();
|
||||
let dir = tmp("stall");
|
||||
let t = Instant::now();
|
||||
let (r, _) = run(&[pinned()], &[format!("{}/stall", srv.base), format!("{}/good", srv.base)], &dir, &AtomicBool::new(false));
|
||||
r.unwrap();
|
||||
assert!(t.elapsed() < Duration::from_secs(4), "{:?}", t.elapsed());
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_quickly_and_keeps_the_part_for_later() {
|
||||
let srv = server();
|
||||
let dir = tmp("cancel");
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let c = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
c.store(true, Ordering::SeqCst);
|
||||
});
|
||||
let t = Instant::now();
|
||||
let (r, _) = run(&[pinned()], &[format!("{}/slow", srv.base)], &dir, &cancel);
|
||||
assert_eq!(r, Err(DownloadError::Cancelled));
|
||||
assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
|
||||
assert!(!dir.join("model.bin").exists());
|
||||
let kept = std::fs::metadata(part_path(&dir, "model.bin")).map(|m| m.len()).unwrap_or(0);
|
||||
assert!(kept > 0 && kept < 300_000, "{kept}");
|
||||
// and the next run resumes it
|
||||
run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mirrors_is_a_clear_error_and_mirror_lists_are_parsed() {
|
||||
let dir = tmp("nomirror");
|
||||
let e = run(&[pinned()], &[], &dir, &AtomicBool::new(false)).0.unwrap_err();
|
||||
assert_eq!(e, DownloadError::NoMirrors);
|
||||
assert!(e.to_string().contains("no download location"));
|
||||
let file = std::env::temp_dir().join(format!("lc-sam3-mirrors-{}.txt", std::process::id()));
|
||||
std::fs::write(&file, "# mine\nhttps://b.example/m/ # second\n\nnot a url\nhttps://a.example/x\n").unwrap();
|
||||
let defaults = ["https://c.example/d", "https://a.example/x"];
|
||||
let m = mirrors(Some("https://a.example/x/, ftp://no"), Some(&file), &defaults);
|
||||
// the environment's, then the file's, then the defaults; a repeated one is kept once
|
||||
assert_eq!(m, ["https://a.example/x", "https://b.example/m", "https://c.example/d"]);
|
||||
assert_eq!(mirrors(None, Some(Path::new("/nonexistent/mirrors.txt")), &defaults).len(), 2);
|
||||
assert!(mirrors(None, None, &[]).is_empty());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_ranges() {
|
||||
assert_eq!(parse_content_range("bytes 100-299/300"), Some((100, Some(300))));
|
||||
assert_eq!(parse_content_range("bytes 0-0/*"), Some((0, None)));
|
||||
assert_eq!(parse_content_range("bytes 9-1/300"), None);
|
||||
assert_eq!(parse_content_range("items 1-2/3"), None);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Affine, Point};
|
||||
use crate::{Affine, Point, Real};
|
||||
|
||||
/// Row-major 3×3 matrix mapping homogeneous points: p' = H·p.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -23,10 +23,15 @@ impl Homography {
|
||||
}
|
||||
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let (x, y) = self.apply_real(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// [`Homography::apply`] for any [`Real`] (e.g. [`Interval`](crate::Interval) bounds over many points).
|
||||
pub fn apply_real<T: Real>(&self, x: T, y: T) -> (T, T) {
|
||||
let m = &self.0;
|
||||
let w = m[6] * p.x + m[7] * p.y + m[8];
|
||||
let w = if w.abs() < 1e-300 { 1e-300 } else { w };
|
||||
Point::new((m[0] * p.x + m[1] * p.y + m[2]) / w, (m[3] * p.x + m[4] * p.y + m[5]) / w)
|
||||
let w = (x * m[6] + y * m[7] + m[8]).clamp_tiny(1e-300);
|
||||
((x * m[0] + y * m[1] + m[2]) / w, (x * m[3] + y * m[4] + m[5]) / w)
|
||||
}
|
||||
|
||||
pub fn mul(&self, o: &Homography) -> Homography {
|
||||
|
||||
+38
-1
@@ -7,9 +7,11 @@
|
||||
|
||||
mod crop;
|
||||
mod homography;
|
||||
mod real;
|
||||
|
||||
pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};
|
||||
pub use homography::Homography;
|
||||
pub use real::{Interval, Real};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -213,8 +215,13 @@ impl Affine {
|
||||
Affine::translate(p.to_vec()) * Affine::rotate(a) * Affine::translate(-p.to_vec())
|
||||
}
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let (x, y) = self.apply_real(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
}
|
||||
/// [`Affine::apply`] for any [`Real`] (e.g. [`Interval`] bounds over many points).
|
||||
pub fn apply_real<T: Real>(&self, x: T, y: T) -> (T, T) {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
Point::new(a * p.x + c * p.y + e, b * p.x + d * p.y + f)
|
||||
(x * a + y * c + e, x * b + y * d + f)
|
||||
}
|
||||
pub fn apply_vec(&self, v: Vec2) -> Vec2 {
|
||||
let [a, b, c, d, ..] = self.0;
|
||||
@@ -332,6 +339,23 @@ impl Orientation {
|
||||
// flipH ∘ rot(t) = rot(-t) ∘ flipH
|
||||
Orientation::from_parts(!f, (4 - t) % 4)
|
||||
}
|
||||
/// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns
|
||||
/// are their own inverses; quarter turns swap.
|
||||
pub fn inverse(self) -> Orientation {
|
||||
match self {
|
||||
Orientation::Rotate90 => Orientation::Rotate270,
|
||||
Orientation::Rotate270 => Orientation::Rotate90,
|
||||
o => o,
|
||||
}
|
||||
}
|
||||
/// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates
|
||||
/// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's
|
||||
/// Rotate Left/Right).
|
||||
pub fn map_norm_rect(self, r: Rect) -> Rect {
|
||||
let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0);
|
||||
let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0);
|
||||
Rect::from_points(Point::new(ax, ay), Point::new(bx, by))
|
||||
}
|
||||
/// Map a point in stored pixel space (w×h stored dims) to oriented space.
|
||||
pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) {
|
||||
let (flip, t) = self.to_parts();
|
||||
@@ -417,6 +441,19 @@ mod tests {
|
||||
assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_inverse_and_norm_rect() {
|
||||
let r = Rect::new(0.1, 0.2, 0.3, 0.6);
|
||||
for v in 1..=8 {
|
||||
let o = Orientation::from_exif(v);
|
||||
let back = o.inverse().map_norm_rect(o.map_norm_rect(r));
|
||||
assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}");
|
||||
}
|
||||
// a quarter turn clockwise: the left of the upright photo becomes its top
|
||||
let q = Orientation::Rotate90.map_norm_rect(r);
|
||||
assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Numbers the coordinate formulas are generic over: `f64` (the reference evaluation) and [`Interval`]
|
||||
//! (bounds on the `f64` results over a whole range of inputs, e.g. a block of output pixels).
|
||||
//!
|
||||
//! Formulas written once over [`Real`] serve both. For `f64` every operator is the plain IEEE operation, so a
|
||||
//! generic formula evaluated with `f64` is bit-identical to the same formula written with `f64` directly, as long
|
||||
//! as it keeps the operation order (`a * k` instead of `k * a` is fine: IEEE `+` and `×` are commutative).
|
||||
|
||||
use std::ops::{Add, Div, Mul, Sub};
|
||||
|
||||
/// A scalar the geometry formulas can be evaluated with. Mixed operations take a parameter (`f64`) on the right.
|
||||
pub trait Real:
|
||||
Copy
|
||||
+ Add<Output = Self>
|
||||
+ Sub<Output = Self>
|
||||
+ Mul<Output = Self>
|
||||
+ Div<Output = Self>
|
||||
+ Add<f64, Output = Self>
|
||||
+ Sub<f64, Output = Self>
|
||||
+ Mul<f64, Output = Self>
|
||||
+ Div<f64, Output = Self>
|
||||
{
|
||||
/// `if |v| < tiny { tiny } else { v }`: the guard of a denominator against zero ([`crate::Homography::apply`]).
|
||||
fn clamp_tiny(self, tiny: f64) -> Self;
|
||||
}
|
||||
|
||||
impl Real for f64 {
|
||||
fn clamp_tiny(self, tiny: f64) -> f64 {
|
||||
if self.abs() < tiny { tiny } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed interval `[lo, hi]` with outward rounding, or *undecided* (both NaN).
|
||||
///
|
||||
/// Invariant: if the `f64` operands of an operation lie in the operand intervals, the `f64` result (rounded to
|
||||
/// nearest) lies in the result interval. Each bound is computed in `f64` and then moved one ulp outward
|
||||
/// (`next_down` / `next_up`); since rounding to nearest is monotonic and errs by less than one ulp, the moved
|
||||
/// bound is on the far side of every rounded result. By induction a formula evaluated with intervals bounds the
|
||||
/// *`f64` evaluation* of the same formula (not only the exact real value) for every input in the input intervals.
|
||||
///
|
||||
/// A result that can't be bounded becomes undecided and stays so: division by an interval that contains zero,
|
||||
/// [`Real::clamp_tiny`] when some value could be clamped, and any infinite or NaN bound. Undecided intervals fail
|
||||
/// every comparison of their bounds, so callers must test the bounds with conditions that are false for NaN.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Interval {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
const UNDECIDED: Interval = Interval { lo: f64::NAN, hi: f64::NAN };
|
||||
|
||||
/// `[lo, hi]` exactly (the bounds are `f64` values already); undecided unless finite with `lo <= hi`.
|
||||
pub fn new(lo: f64, hi: f64) -> Interval {
|
||||
if lo.is_finite() && hi.is_finite() && lo <= hi { Interval { lo, hi } } else { Interval::UNDECIDED }
|
||||
}
|
||||
|
||||
/// The lower bound (NaN if undecided).
|
||||
pub fn lo(self) -> f64 {
|
||||
self.lo
|
||||
}
|
||||
|
||||
/// The upper bound (NaN if undecided).
|
||||
pub fn hi(self) -> f64 {
|
||||
self.hi
|
||||
}
|
||||
|
||||
/// Whether the bounds are usable (not undecided).
|
||||
pub fn is_decided(self) -> bool {
|
||||
self.lo <= self.hi
|
||||
}
|
||||
|
||||
/// Bounds computed in round-to-nearest, moved one ulp outward.
|
||||
fn outward(lo: f64, hi: f64) -> Interval {
|
||||
Interval::new(lo.next_down(), hi.next_up())
|
||||
}
|
||||
|
||||
/// The hull of four products or quotients, moved outward. NaN in any of them makes it undecided.
|
||||
fn hull(v: [f64; 4]) -> Interval {
|
||||
if v.iter().any(|v| !v.is_finite()) {
|
||||
return Interval::UNDECIDED;
|
||||
}
|
||||
let lo = v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
Interval::outward(lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Interval {
|
||||
fn from(v: f64) -> Interval {
|
||||
Interval::new(v, v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Interval {
|
||||
type Output = Interval;
|
||||
fn add(self, o: Interval) -> Interval {
|
||||
Interval::outward(self.lo + o.lo, self.hi + o.hi)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Interval {
|
||||
type Output = Interval;
|
||||
fn sub(self, o: Interval) -> Interval {
|
||||
Interval::outward(self.lo - o.hi, self.hi - o.lo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul for Interval {
|
||||
type Output = Interval;
|
||||
fn mul(self, o: Interval) -> Interval {
|
||||
Interval::hull([self.lo * o.lo, self.lo * o.hi, self.hi * o.lo, self.hi * o.hi])
|
||||
}
|
||||
}
|
||||
|
||||
impl Div for Interval {
|
||||
type Output = Interval;
|
||||
/// Undecided when the divisor may be zero (or is undecided).
|
||||
fn div(self, o: Interval) -> Interval {
|
||||
if !(o.lo > 0.0 || o.hi < 0.0) {
|
||||
return Interval::UNDECIDED;
|
||||
}
|
||||
Interval::hull([self.lo / o.lo, self.lo / o.hi, self.hi / o.lo, self.hi / o.hi])
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<f64> for Interval {
|
||||
type Output = Interval;
|
||||
fn add(self, k: f64) -> Interval {
|
||||
Interval::outward(self.lo + k, self.hi + k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<f64> for Interval {
|
||||
type Output = Interval;
|
||||
fn sub(self, k: f64) -> Interval {
|
||||
Interval::outward(self.lo - k, self.hi - k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for Interval {
|
||||
type Output = Interval;
|
||||
fn mul(self, k: f64) -> Interval {
|
||||
let (a, b) = (self.lo * k, self.hi * k);
|
||||
if k >= 0.0 { Interval::outward(a, b) } else { Interval::outward(b, a) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<f64> for Interval {
|
||||
type Output = Interval;
|
||||
/// Undecided when `k` is zero (or NaN).
|
||||
fn div(self, k: f64) -> Interval {
|
||||
let (a, b) = (self.lo / k, self.hi / k);
|
||||
if k > 0.0 {
|
||||
Interval::outward(a, b)
|
||||
} else if k < 0.0 {
|
||||
Interval::outward(b, a)
|
||||
} else {
|
||||
Interval::UNDECIDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Real for Interval {
|
||||
/// Unchanged when no value in the interval would be clamped, else undecided.
|
||||
fn clamp_tiny(self, tiny: f64) -> Interval {
|
||||
if self.lo >= tiny || self.hi <= -tiny { self } else { Interval::UNDECIDED }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn contains(i: Interval, v: f64) -> bool {
|
||||
i.lo <= v && v <= i.hi
|
||||
}
|
||||
|
||||
/// A value inside `[lo, hi]` at fraction `t`.
|
||||
fn pick(i: Interval, t: f64) -> f64 {
|
||||
(i.lo + (i.hi - i.lo) * t).clamp(i.lo, i.hi)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Every `f64` result of `a ∘ b` with `a ∈ A`, `b ∈ B` lies in `A ∘ B` (or that is undecided).
|
||||
#[test]
|
||||
fn operations_enclose_the_f64_results(
|
||||
a0 in -1e6f64..1e6, aw in 0.0f64..1e3, b0 in -1e3f64..1e3, bw in 0.0f64..10.0,
|
||||
ta in 0.0f64..=1.0, tb in 0.0f64..=1.0, k in -1e3f64..1e3,
|
||||
) {
|
||||
let (ia, ib) = (Interval::new(a0, a0 + aw), Interval::new(b0, b0 + bw));
|
||||
let (a, b) = (pick(ia, ta), pick(ib, tb));
|
||||
prop_assert!(contains(ia + ib, a + b));
|
||||
prop_assert!(contains(ia - ib, a - b));
|
||||
prop_assert!(contains(ia * ib, a * b));
|
||||
let q = ia / ib;
|
||||
prop_assert!(!q.is_decided() || contains(q, a / b));
|
||||
prop_assert!(contains(ia + k, a + k));
|
||||
prop_assert!(contains(ia - k, a - k));
|
||||
prop_assert!(contains(ia * k, a * k));
|
||||
prop_assert!(k == 0.0 || contains(ia / k, a / k));
|
||||
let c = ib.clamp_tiny(1.0);
|
||||
prop_assert!(!c.is_decided() || contains(c, b.clamp_tiny(1.0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_results_are_undecided() {
|
||||
let i = Interval::new(-1.0, 2.0);
|
||||
assert!(!(i / Interval::new(-1e-9, 1.0)).is_decided());
|
||||
assert!(!(i / Interval::new(0.0, 1.0)).is_decided());
|
||||
assert!((i / Interval::new(1e-9, 1.0)).is_decided());
|
||||
assert!(!(i / 0.0).is_decided());
|
||||
assert!(!(Interval::new(f64::MAX, f64::MAX) * 2.0).is_decided());
|
||||
assert!(!Interval::new(2.0, 1.0).is_decided());
|
||||
assert!(!Interval::new(f64::NAN, 1.0).is_decided());
|
||||
// undecided propagates through everything
|
||||
let u = Interval::UNDECIDED;
|
||||
for r in [u + i, i - u, u * i, i / u, u + 1.0, u * 0.0, u.clamp_tiny(1e-300)] {
|
||||
assert!(!r.is_decided());
|
||||
assert!(r.lo.is_nan() && r.hi.is_nan(), "undecided bounds are NaN, which fails every comparison");
|
||||
}
|
||||
// the denominator guard: unchanged only when no value is clamped
|
||||
assert!(Interval::new(1e-300, 1.0).clamp_tiny(1e-300).is_decided());
|
||||
assert!(Interval::new(-1.0, -1e-300).clamp_tiny(1e-300).is_decided());
|
||||
assert!(!Interval::new(-1.0, 1.0).clamp_tiny(1e-300).is_decided());
|
||||
assert!(!Interval::new(5e-301, 1.0).clamp_tiny(1e-300).is_decided());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ const MODULES: &[Module] = &[
|
||||
},
|
||||
Module {
|
||||
src: include_str!("wgsl/geom.wgsl"),
|
||||
bindings: &[("src", false, "f32"), ("dst", true, "f32")],
|
||||
bindings: &[("src", false, "f32"), ("dst", true, "f32"), ("coverage", false, "u32")],
|
||||
entries: &["orient", "sample_affine", "sample_warp"],
|
||||
},
|
||||
];
|
||||
@@ -233,14 +233,16 @@ pub struct Gpu {
|
||||
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
|
||||
fn constants() -> String {
|
||||
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
|
||||
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
|
||||
use lightcraft_pipeline::tone::{CHROMA_N, LUT_MAX_EV, LUT_MIN_EV, LUT_N};
|
||||
let mut s = String::new();
|
||||
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
|
||||
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] {
|
||||
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
|
||||
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
|
||||
}
|
||||
s += &format!("const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\n");
|
||||
s += &format!(
|
||||
"const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\nconst CHROMA_N: u32 = {CHROMA_N}u;\n"
|
||||
);
|
||||
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
|
||||
let b = lightcraft_pipeline::geometry::BLANK;
|
||||
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
|
||||
@@ -276,14 +278,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);
|
||||
|
||||
+33
-9
@@ -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();
|
||||
});
|
||||
|
||||
@@ -136,10 +136,11 @@ pub struct Present {
|
||||
pub chroma: bool,
|
||||
}
|
||||
|
||||
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs |
|
||||
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | chroma curve | sRGB LUT | curve LUTs |
|
||||
/// mask terms) for `fp` with `masks` (their local adjustments' terms).
|
||||
pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Present) -> (Vec<u32>, Vec<f32>) {
|
||||
let mut aux: Vec<f32> = fp.tone.lut().to_vec();
|
||||
aux.extend_from_slice(fp.tone.chroma_lut());
|
||||
let srgb_off = aux.len();
|
||||
aux.extend_from_slice(&srgb_lut()[..]);
|
||||
let curve_off = aux.len();
|
||||
|
||||
+109
-3
@@ -410,6 +410,42 @@ fn warp_params(
|
||||
p
|
||||
}
|
||||
|
||||
/// Output rows per block of [`coverage_mask`]; a block is one mask word (32 px) wide. 16 rows measured
|
||||
/// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels).
|
||||
const COVER_ROWS: usize = 16;
|
||||
|
||||
/// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every
|
||||
/// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose
|
||||
/// interval bounds place them clearly inside or outside the image are filled at once
|
||||
/// ([`Warp::block_coverage`](lightcraft_pipeline::optics::Warp::block_coverage), the same formulas evaluated
|
||||
/// with outward-rounded intervals); only blocks near the image edge evaluate each pixel. Same bits as
|
||||
/// evaluating every pixel.
|
||||
fn coverage_mask(wp: &lightcraft_pipeline::optics::Warp, o2t: &lightcraft_geom::Affine, w: usize, h: usize) -> Vec<u32> {
|
||||
let words = w.div_ceil(32);
|
||||
let mut coverage = vec![0u32; words * h];
|
||||
if words == 0 {
|
||||
return coverage;
|
||||
}
|
||||
lightcraft_raster::par_rows(&mut coverage, words * COVER_ROWS, |band, rows| {
|
||||
let y0 = band * COVER_ROWS;
|
||||
let y1 = y0 + rows.len() / words;
|
||||
for word in 0..words {
|
||||
let (x0, x1) = (word * 32, (word * 32 + 32).min(w));
|
||||
let full = if x1 - x0 == 32 { u32::MAX } else { (1u32 << (x1 - x0)) - 1 };
|
||||
let block = wp.block_coverage(o2t, x0, x1, y0, y1);
|
||||
for (y, row) in (y0..y1).zip(rows.chunks_exact_mut(words)) {
|
||||
let Some(bits) = row.get_mut(word) else { continue };
|
||||
*bits = match block {
|
||||
Some(true) => full,
|
||||
Some(false) => 0,
|
||||
None => (x0..x1).filter(|&x| wp.covers(o2t, x, y)).fold(0, |b, x| b | 1 << (x - x0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
coverage
|
||||
}
|
||||
|
||||
/// Resample the source into the output frame (`Frame::sample`). May return the source buffer
|
||||
/// itself when the frame is the identity.
|
||||
fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>) -> Arc<Buf> {
|
||||
@@ -422,7 +458,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
let out = cx.gpu.buffer(sp.ow * sp.oh * 3);
|
||||
let mut p = vec![src.width as u32, src.height as u32, sp.ow as u32, sp.oh as u32];
|
||||
p.extend(orient_map(fr.orient, src.width, src.height).map(|v| v as u32));
|
||||
cx.run("orient", &p, &[Some(&src_buf), Some(&out)], groups2(sp.ow, sp.oh, [16, 16]));
|
||||
cx.run("orient", &p, &[Some(&src_buf), Some(&out), None], groups2(sp.ow, sp.oh, [16, 16]));
|
||||
Arc::new(out)
|
||||
};
|
||||
let (base, (bw, bh)) = match sp.prefilter {
|
||||
@@ -433,7 +469,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
|
||||
p.extend(affine_bits(xf));
|
||||
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
cx.run("sample_affine", &p, &[Some(&base), Some(&out), None], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
};
|
||||
match (&sp.mode, fr.warp.as_ref()) {
|
||||
@@ -444,7 +480,10 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
(SampleMode::Warp(o2t), Some(wp)) => {
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
|
||||
cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
// f32 rounding can cross a source edge. Keep the reference f64
|
||||
// framing decision in a bit mask; color sampling stays on the GPU.
|
||||
let coverage = cx.gpu.upload(&coverage_mask(wp, o2t, w, h));
|
||||
cx.run("sample_warp", &p, &[Some(&base), Some(&out), Some(&coverage)], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
}
|
||||
}
|
||||
@@ -950,6 +989,73 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_mask_matches_per_pixel_decision() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedWarp};
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp {
|
||||
planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002]; 3],
|
||||
center: lightcraft_geom::Point::new(0.52, 0.48),
|
||||
radius: 0.6,
|
||||
}),
|
||||
vignette: None,
|
||||
};
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.lens_profile = true;
|
||||
s.optics.distortion = -40.0;
|
||||
s.geometry.horizontal = -15.0;
|
||||
s.geometry.rotate = 3.0;
|
||||
for orientation in [Orientation::Normal, Orientation::Rotate270] {
|
||||
s.orientation = orientation;
|
||||
let f = lightcraft_pipeline::geometry::Frame::with_lens(900, 600, &s, true, Some(&lens));
|
||||
let wp = f.warp.as_ref().expect("warp");
|
||||
// widths that do and don't fill the last word; heights that do and don't fill the last band
|
||||
for (w, h) in [f.fit(700, 700), (333, 221), (64, 16), (1, 1)] {
|
||||
let o2t = f.out_to_oriented(w, h);
|
||||
let mask = coverage_mask(wp, &o2t, w, h);
|
||||
let words = w.div_ceil(32);
|
||||
assert_eq!(mask.len(), words * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bit = mask[y * words + x / 32] >> (x % 32) & 1 == 1;
|
||||
assert_eq!(bit, wp.covers(&o2t, x, y), "{orientation:?} {w}x{h}: pixel {x},{y}");
|
||||
}
|
||||
let pad = mask[y * words + words - 1] >> (w % 32);
|
||||
assert!(w.is_multiple_of(32) || pad == 0, "padding bits set");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The perspective horizon in view: its denominator is exactly 0 on output row 75 (clamped to 1e-300) and
|
||||
/// changes sign there, so the blocks around it are undecided (clamp, and division by a range that may hold
|
||||
/// zero) and are decided per pixel, while blocks clear of it are filled at once.
|
||||
#[test]
|
||||
fn coverage_mask_with_the_horizon_in_view() {
|
||||
use lightcraft_geom::{Affine, Homography};
|
||||
let (w, h) = (900, 600);
|
||||
let mut wp = lightcraft_pipeline::optics::Warp::identity(w as f64, h as f64);
|
||||
// centred coordinates: v = (y − 300) / 450, denominator 2·v + m8, exactly 0 at y = 75.5
|
||||
let m8 = -((75.5 - 300.0) / 450.0 * 2.0);
|
||||
wp.persp_inv = Homography([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, m8]);
|
||||
wp.persp = wp.persp_inv.inverse().expect("invertible");
|
||||
let o2t = Affine::IDENTITY;
|
||||
assert_eq!(wp.block_coverage(&o2t, 0, 32, 64, 80), None, "the block holding the horizon");
|
||||
assert_eq!(wp.block_coverage(&o2t, 0, 32, 0, 16), Some(false), "beyond the horizon");
|
||||
assert_eq!(wp.block_coverage(&o2t, 448, 480, 288, 304), Some(true), "the centre");
|
||||
let mask = coverage_mask(&wp, &o2t, w, h);
|
||||
let words = w.div_ceil(32);
|
||||
let mut covered = 0;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bit = mask[y * words + x / 32] >> (x % 32) & 1 == 1;
|
||||
assert_eq!(bit, wp.covers(&o2t, x, y), "pixel {x},{y}");
|
||||
covered += bit as usize;
|
||||
}
|
||||
}
|
||||
assert!(covered > 0 && covered < w * h, "{covered} px covered");
|
||||
}
|
||||
|
||||
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it).
|
||||
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the
|
||||
// blurred chromaticity when HAS_CHROMA), aux
|
||||
// (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
|
||||
// (tone LUT | chroma curve | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
|
||||
|
||||
fn tone_apply(y: f32) -> f32 {
|
||||
if (y <= 0.0) {
|
||||
@@ -18,6 +18,19 @@ fn tone_apply(y: f32) -> f32 {
|
||||
return v;
|
||||
}
|
||||
|
||||
// The camera chroma curve follows the tone LUT in `aux` (`ToneMap::chroma_scale`).
|
||||
fn chroma_scale(o: f32) -> f32 {
|
||||
let f = clamp(o, 0.0, 1.0) * f32(CHROMA_N - 1u);
|
||||
let i = min(u32(f), CHROMA_N - 2u);
|
||||
let t = f - f32(i);
|
||||
let a = aux[TONE_N + i];
|
||||
let b = aux[TONE_N + i + 1u];
|
||||
if (a == b) {
|
||||
return a;
|
||||
}
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
fn encode_srgb(v: f32) -> f32 {
|
||||
let o = pu(F_SRGB_OFF);
|
||||
let f = clamp(v, 0.0, 1.0) * f32(SRGB_N);
|
||||
@@ -400,6 +413,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (yl > 1e-9) {
|
||||
d = c * o / yl;
|
||||
}
|
||||
let k = chroma_scale(o);
|
||||
if (k != 1.0) {
|
||||
d = vec3<f32>(o) + (d - vec3<f32>(o)) * k;
|
||||
}
|
||||
let mx = max(d.x, max(d.y, d.z));
|
||||
if (mx > 1.0) {
|
||||
let t = clamp((mx - 1.0) / max(mx - o, 1e-6), 0.0, 1.0);
|
||||
|
||||
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user