Compare commits
No files matched your search
@@ -0,0 +1,55 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# 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'
|
||||
@@ -1,8 +1,16 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/target-*/
|
||||
/dist
|
||||
/corpus
|
||||
/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,12 +1,17 @@
|
||||
# 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).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
@@ -17,6 +22,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
|
||||
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
|
||||
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
|
||||
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
|
||||
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
@@ -29,9 +62,21 @@ and often so a crash loses minutes, not hours.
|
||||
## 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
|
||||
@@ -57,6 +102,7 @@ and often so a crash loses minutes, not hours.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## Testing & performance (do this often)
|
||||
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
|
||||
@@ -73,4 +119,25 @@ and often so a crash loses minutes, not hours.
|
||||
## 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,12 +1,17 @@
|
||||
# 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).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
@@ -17,6 +22,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
|
||||
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
|
||||
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
|
||||
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
|
||||
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
@@ -29,9 +62,21 @@ and often so a crash loses minutes, not hours.
|
||||
## 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
|
||||
@@ -57,6 +102,7 @@ and often so a crash loses minutes, not hours.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## Testing & performance (do this often)
|
||||
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
|
||||
@@ -73,4 +119,7 @@ and often so a crash loses minutes, not hours.
|
||||
## 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
+1492
-29
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
lightcraft-tiff = { path = "crates/tiff" }
|
||||
@@ -28,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"
|
||||
@@ -61,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"
|
||||
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,12 +1,34 @@
|
||||
LightCraft
|
||||
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
This product bundles third-party material under the following licences:
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
|
||||
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Third-party material
|
||||
|
||||
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
|
||||
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
|
||||
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -280,9 +281,18 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast; see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row
|
||||
Lightroom parity tracker (every feature, menu item and shortcut with its status, evidence and the top gaps) is
|
||||
[docs/parity.md](docs/parity.md).
|
||||
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity.md](docs/parity.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
|
||||
older-Canon raws on one machine.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
|
||||
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **HDR, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
@@ -292,17 +302,20 @@ Lightroom parity tracker (every feature, menu item and shortcut with its status,
|
||||
| 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; 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), Fujifilm RAF (Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF; embedded previews for every format incl. CR3 | ✅ · compressed NEF/RAF/ORF, CR3 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, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
|
||||
| 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 ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255) with CPU fallback | ✅ · WebGPU in the browser 🚧 |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
@@ -322,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.
|
||||
@@ -344,14 +403,65 @@ 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
|
||||
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
|
||||
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
|
||||
|
||||
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).
|
||||
|
||||
<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
|
||||
@@ -364,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) |
|
||||
|
||||
@@ -396,12 +506,23 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft is licensed under **MIT OR Apache-2.0**.
|
||||
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
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). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
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
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
@@ -409,3 +530,7 @@ Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTR
|
||||
<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)
|
||||
+117
-25
@@ -4,6 +4,71 @@ Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every
|
||||
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
|
||||
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
|
||||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
|
||||
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
|
||||
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
|
||||
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
|
||||
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
### By dimension
|
||||
|
||||
| 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) | ~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; 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 / 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% | 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. 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
|
||||
checked for plausible colour; fix per-model bugs (#85).
|
||||
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
|
||||
(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. 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.
|
||||
|
||||
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
|
||||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
|
||||
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|
||||
@@ -11,7 +76,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
|
||||
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
|
||||
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed, embedded previews ✅; NEF Huffman ⬜) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
|
||||
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
|
||||
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
|
||||
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
|
||||
@@ -19,24 +84,27 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| 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 ✅; CR3, compressed NEF/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 (2026-10-02, evening update)
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows | Morning |
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.7%** | 198 | 98.5% |
|
||||
| P1 (important parity) | **94.4%** | 144 | 79.9% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **29.4%** | 158 | 11.7% |
|
||||
| **All in-scope rows** | **75.6%** | 500 | 65.7% |
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
|
||||
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
|
||||
|
||||
@@ -50,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 NEF / ORF / RAF, 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 |
|
||||
@@ -86,32 +154,36 @@ The milestone estimates in the table above were made before work started and are
|
||||
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
|
||||
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
|
||||
then our own calibration; long-tail camera/lens coverage grows over time.
|
||||
- **Legal decisions pending:** whether GPL-licensed *prose* format descriptions (e.g. the public CR3 write-up) may be read
|
||||
by a designated engineer to produce an internal spec; freedom-to-operate review for local Laplacian filters, PatchMatch and
|
||||
HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is subjective tuning against our own reference targets.
|
||||
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
|
||||
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
|
||||
Laplacian filters, PatchMatch and HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
|
||||
turns it into measured comparisons against local-only Lightroom references.
|
||||
|
||||
## 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, 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.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon Huffman NEF** (lossless / lossy). Black-box findings so far: maker note `0x0096` holds per-parity predictor
|
||||
seeds (2048 for 14-bit lossless); ≈ 9 bits per pixel; the category-0 code word is a 6-bit rotation of `011111`. The
|
||||
Huffman tables are not stored in the files, and an automatic table search (beam search over prefix codes scored by
|
||||
smoothness and bit rate, validated on Pentax files whose tables *are* stored) did not converge.
|
||||
- **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.
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
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).
|
||||
- **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
|
||||
@@ -119,3 +191,23 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
|
||||
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).
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
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
|
||||
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
|
||||
@@ -15,6 +15,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-mcp = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Give memory the system allocator keeps after frees back to the system
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]).
|
||||
//!
|
||||
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
|
||||
//! process's memory footprint but stay in its resident size until the system runs short. After a
|
||||
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
|
||||
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
|
||||
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
|
||||
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
|
||||
|
||||
/// Install the hook for this platform.
|
||||
/// Install the hook.
|
||||
pub fn install() {
|
||||
#[cfg(target_os = "macos")]
|
||||
lightcraft_engine::memory::set_release_hook(macos::release);
|
||||
lightcraft_engine::memory::set_release_hook(release);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
mod macos {
|
||||
unsafe extern "C" {
|
||||
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
|
||||
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
|
||||
pub fn release() {
|
||||
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
|
||||
// it only returns free pages to the system and touches no memory we own.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
fn release() {
|
||||
let _ = lightcraft_sysmem::release_free_memory();
|
||||
}
|
||||
+187
-52
@@ -9,7 +9,10 @@
|
||||
//! 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)]
|
||||
|
||||
mod alloc_release;
|
||||
|
||||
@@ -82,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
|
||||
";
|
||||
|
||||
@@ -89,6 +98,17 @@ USAGE:
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
|
||||
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
|
||||
@@ -112,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(())
|
||||
@@ -131,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;
|
||||
@@ -180,7 +309,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
h
|
||||
}
|
||||
@@ -283,14 +412,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
|
||||
let mut written = Vec::new();
|
||||
match kind {
|
||||
"hdr" => {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
|
||||
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
|
||||
std::fs::write(&p, b).map_err(|e| e.to_string())?;
|
||||
written.push(p);
|
||||
}
|
||||
}
|
||||
"panorama" | "pano" => {
|
||||
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() {
|
||||
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let p = Path::new(&dir).join(format!("view-{k}.png"));
|
||||
let img = v.to_srgb8();
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
|
||||
@@ -399,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| e.to_string())?;
|
||||
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 {
|
||||
@@ -457,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;
|
||||
@@ -527,11 +666,17 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?;
|
||||
for (ext, bytes) in &e.sidecars {
|
||||
let sc = Path::new(&output).with_extension(ext);
|
||||
std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?;
|
||||
eprintln!("lightcraft-cli: wrote {}", sc.display());
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect();
|
||||
let guard = s.original_guard();
|
||||
for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) {
|
||||
guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?;
|
||||
}
|
||||
lightcraft_engine::export::write_file(&output, &e.bytes)?;
|
||||
for (sc, bytes) in &sidecars {
|
||||
lightcraft_engine::export::write_file(sc, bytes)?;
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
Ok(())
|
||||
@@ -574,7 +719,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(dir, false).map_err(|e| format!("{dir}: {e}"))?;
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
@@ -587,18 +732,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let services = lightcraft_ui_egui::Services {
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
|
||||
})),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
|
||||
@@ -86,6 +86,24 @@ fn render_subcommand() {
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
|
||||
}
|
||||
|
||||
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
|
||||
#[test]
|
||||
fn render_refuses_to_overwrite_its_input() {
|
||||
let input = tmp("self-in.png");
|
||||
gradient_png(&input);
|
||||
let before = std::fs::read(&input).unwrap();
|
||||
let dir = input.parent().unwrap();
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let other_spelling = dir.join("sub/../self-in.png");
|
||||
for out in [&input, &other_spelling] {
|
||||
let o =
|
||||
Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
assert_eq!(std::fs::read(&input).unwrap(), before, "the input is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_export_options() {
|
||||
let input = tmp("o-in.png");
|
||||
@@ -165,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 {
|
||||
@@ -238,7 +292,47 @@ 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}");
|
||||
}
|
||||
|
||||
/// Issue #99: a library open in one process (here `mcp --library`) is refused by a second one,
|
||||
/// with who has it and how to drive the running app instead; free again once the first exits.
|
||||
#[test]
|
||||
fn a_library_open_in_another_process_is_refused() {
|
||||
let lib = tmp("locked-lib");
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
let lib_s = lib.to_str().unwrap();
|
||||
let mut holder =
|
||||
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut err = BufReader::new(holder.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("opened library") {
|
||||
line.clear();
|
||||
assert!(err.read_line(&mut line).unwrap() > 0, "mcp exited before opening the library");
|
||||
}
|
||||
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(!ok);
|
||||
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
|
||||
assert!(stderr.contains("mcp --connect"), "{stderr}");
|
||||
|
||||
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
|
||||
assert!(holder.wait().unwrap().success());
|
||||
let (ok, lines, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(ok, "{stderr}");
|
||||
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();
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
|
||||
//! persistence and `?workers=0` renders on the main thread.
|
||||
#![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;
|
||||
@@ -23,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();
|
||||
}
|
||||
+176
-36
@@ -15,37 +15,53 @@ 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;
|
||||
|
||||
fn window() -> web_sys::Window {
|
||||
web_sys::window().expect("no window")
|
||||
/// The page's window (`None` only outside a browser page, e.g. in a worker).
|
||||
fn window() -> Option<web_sys::Window> {
|
||||
web_sys::window()
|
||||
}
|
||||
|
||||
/// Milliseconds since navigation start.
|
||||
fn perf_now() -> f64 {
|
||||
window().performance().map(|p| p.now()).unwrap_or(0.0)
|
||||
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn set_status(text: &str) {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
el.set_text_content(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
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`).
|
||||
@@ -59,7 +75,7 @@ struct Options {
|
||||
|
||||
impl Options {
|
||||
fn from_url() -> Options {
|
||||
let q = window().location().search().unwrap_or_default();
|
||||
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
|
||||
let params: Vec<(String, String)> = q
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
@@ -85,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();
|
||||
@@ -102,7 +119,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
|
||||
|
||||
/// Show the browser's open dialog; picked files are imported asynchronously.
|
||||
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
|
||||
let Some(doc) = window().document() else { return };
|
||||
let Some(doc) = window().and_then(|w| w.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_multiple(true);
|
||||
@@ -131,7 +148,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
opts.set_type(crate::store::mime_for(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
|
||||
let doc = window().document().ok_or("no document")?;
|
||||
let doc = window().and_then(|w| w.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);
|
||||
@@ -140,20 +157,59 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
if let Some(w) = window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
}
|
||||
log::info!("exported {name} ({} bytes)", bytes.len());
|
||||
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
|
||||
})),
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
write: Some(Box::new(download)),
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
@@ -170,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;
|
||||
}
|
||||
}
|
||||
@@ -198,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 {
|
||||
@@ -215,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}"),
|
||||
@@ -232,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
|
||||
@@ -260,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 {
|
||||
@@ -270,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,
|
||||
@@ -278,17 +373,18 @@ 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,
|
||||
};
|
||||
let mut problem = None;
|
||||
match session.open_library_in(stores, true).cloned() {
|
||||
Ok(r) => log::info!(
|
||||
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
@@ -299,25 +395,42 @@ impl WebApp {
|
||||
r.replayed
|
||||
),
|
||||
Err(e) => {
|
||||
log::error!("opening the library failed: {e}; starting a temporary one");
|
||||
session = Session::with_demo();
|
||||
// never a silent demo session (issue #100): an empty one, and the window says why
|
||||
log::error!("opening the library failed: {e}");
|
||||
session = Session::new();
|
||||
originals.install(&mut session);
|
||||
problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem {
|
||||
can_retry: false,
|
||||
..lightcraft_ui_egui::panels::library_problem::LibraryProblem::new(
|
||||
"the browser's storage for this page",
|
||||
format!("{e}. Reload the page to try again."),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
app.library_problem = problem;
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().navigator().hardware_concurrency() as usize;
|
||||
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()));
|
||||
}
|
||||
@@ -331,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,
|
||||
@@ -392,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;
|
||||
@@ -413,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,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,
|
||||
@@ -474,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);
|
||||
@@ -511,11 +640,22 @@ 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 {
|
||||
let Some(canvas) =
|
||||
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
// 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"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
log::error!("missing <canvas id=\"lightcraft_canvas\">");
|
||||
return;
|
||||
@@ -525,7 +665,7 @@ pub fn start() {
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
+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,8 @@ 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 }
|
||||
lightcraft-raster = { workspace = true }
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Give memory the system allocator keeps after frees back to the system
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]).
|
||||
//!
|
||||
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
|
||||
//! process's memory footprint but stay in its resident size until the system runs short. After a
|
||||
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
|
||||
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
|
||||
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
|
||||
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
|
||||
|
||||
/// Install the hook for this platform.
|
||||
/// Install the hook.
|
||||
pub fn install() {
|
||||
#[cfg(target_os = "macos")]
|
||||
lightcraft_engine::memory::set_release_hook(macos::release);
|
||||
lightcraft_engine::memory::set_release_hook(release);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
mod macos {
|
||||
unsafe extern "C" {
|
||||
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
|
||||
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
|
||||
pub fn release() {
|
||||
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
|
||||
// it only returns free pages to the system and touches no memory we own.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
fn release() {
|
||||
let _ = lightcraft_sysmem::release_free_memory();
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,235 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the connection
|
||||
// closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7980\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"folder.move\"}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty());
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused_without_buffering_it_all() {
|
||||
// An endless line without a newline: reading stops after MAX_LINE + 1 bytes.
|
||||
let endless = std::io::repeat(b'x');
|
||||
let mut out = Vec::new();
|
||||
let mut ran = false;
|
||||
serve_lines(endless, &mut out, |_, _| {
|
||||
ran = true;
|
||||
None
|
||||
});
|
||||
assert!(!ran);
|
||||
let reply: Value = serde_json::from_slice(out.strip_suffix(b"\n").unwrap()).unwrap();
|
||||
assert!(reply["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
}
|
||||
+367
-54
@@ -7,11 +7,20 @@
|
||||
//! `~/Pictures/LightCraft Library`; a new library starts with the
|
||||
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
||||
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts).
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
|
||||
//! that can't be opened is never replaced silently: the window says why and asks what to do
|
||||
//! (`lightcraft_ui_egui::panels::library_problem`).
|
||||
//!
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `lightcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
|
||||
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
|
||||
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
@@ -19,6 +28,7 @@ mod control_server;
|
||||
mod native_menu;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
|
||||
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
|
||||
@@ -34,15 +44,16 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.1.as_mut() {
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -51,50 +62,177 @@ impl eframe::App for App {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any.
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
/// 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;
|
||||
}
|
||||
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
|
||||
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
|
||||
config_dir().map(|d| d.join("gpu-init.marker"))
|
||||
}
|
||||
|
||||
fn save_prefs(app: &LightcraftApp) {
|
||||
/// 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
|
||||
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
||||
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
||||
/// one that can't be read at all is not written this session (`keep_file`).
|
||||
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
return (None, None, false);
|
||||
}
|
||||
if let Some(d) = config_dir() {
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(d.join("ui.json"), bytes);
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
Err(e) => {
|
||||
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
Err(e) => {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
||||
let kept = std::fs::rename(path, &keep);
|
||||
let what = match &kept {
|
||||
Ok(()) => format!("it was kept as {}", keep.display()),
|
||||
Err(r) => format!("it couldn't be set aside ({r})"),
|
||||
};
|
||||
let msg = format!(
|
||||
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
|
||||
path.display()
|
||||
);
|
||||
(None, Some(msg), kept.is_err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
|
||||
/// disk leaves the old file or the new one, never a truncated one).
|
||||
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let written = (|| {
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(dir) = path.parent()
|
||||
&& let Ok(d) = std::fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
||||
/// changes, every few seconds when anything else changed, and at exit.
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
/// The last write failed (reported once until a write works).
|
||||
failing: bool,
|
||||
/// `ui.json` couldn't be read at launch: never overwrite it this session.
|
||||
keep_file: bool,
|
||||
}
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(d) = config_dir() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&d)
|
||||
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let moved = app.ui.settings.library_path != self.library;
|
||||
if !moved && now - self.checked < 3.0 {
|
||||
return;
|
||||
}
|
||||
self.checked = now;
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
// don't retry every frame
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -146,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()
|
||||
@@ -160,49 +298,76 @@ 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", "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()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.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())
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| e.to_string())
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.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()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| e.to_string())
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.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())
|
||||
})),
|
||||
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
|
||||
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return fallback();
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
|
||||
return fallback();
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
@@ -216,11 +381,11 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
t0.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
s
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
|
||||
fallback()
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,9 +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();
|
||||
@@ -268,7 +470,7 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let prefs = load_prefs();
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -278,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")
|
||||
@@ -290,23 +497,48 @@ 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 = 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
|
||||
let writer = PrefsWriter {
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
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);
|
||||
}
|
||||
if !files.is_empty() {
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
@@ -316,9 +548,90 @@ fn main() -> eframe::Result {
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
fn damaged_ui_json_is_kept_and_reported() {
|
||||
let d = dir("damaged");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
assert!(!path.exists());
|
||||
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
||||
// a missing file is just "no settings yet"
|
||||
assert_eq!(load_prefs_at(&path).1, None);
|
||||
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");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_atomic(&path, b"{\"new\": true}").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
assert!(!d.join("ui.json.tmp").exists());
|
||||
// a failed write leaves the old file
|
||||
let missing = d.join("gone").join("ui.json");
|
||||
assert!(write_atomic(&missing, b"x").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
let ui = UiState::default();
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
||||
let (loaded, warning, _) = load_prefs_at(&path);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -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, "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);
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
@@ -247,10 +247,16 @@ impl NativeMenu {
|
||||
self.append_nodes(&sub, &nodes);
|
||||
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 == "Window" {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
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 == "Help" {
|
||||
@@ -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)));
|
||||
|
||||
@@ -10,6 +10,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
|---|---|---|---|---|---|---|
|
||||
| `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/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 |
|
||||
@@ -17,7 +18,13 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `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
|
||||
@@ -0,0 +1,4 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -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,11 +10,13 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Catalog persistence costs on large synthetic catalogs (issue #37): log append, compaction
|
||||
//! (snapshot serialise / write+fsync / log reset), synchronous and on the background worker, load,
|
||||
//! and a catalog clone.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p lightcraft-catalog --example persist_bench -- <dir> [photos…]
|
||||
//! ```
|
||||
//!
|
||||
//! `<dir>` is a scratch directory (wiped per size). Default sizes: 1000 10000 85000. 90 % of the
|
||||
//! photos are Local browse records, the rest library photos with a few edits and history steps.
|
||||
//! Peak memory: run under `/usr/bin/time -l` (macOS) / `-v` (Linux) with one size;
|
||||
//! `PERSIST_BENCH_NO_LOAD=1` skips the final load, so the peak is building + compacting.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::*;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
fn photo(c: &mut Catalog, i: u64) -> Photo {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(
|
||||
id,
|
||||
Source::File { path: format!("/Users/someone/Pictures/2026/09/{:05}/IMG_{i:05}.CR3", i / 500) },
|
||||
&format!("IMG_{i:05}.CR3"),
|
||||
"CR3",
|
||||
6000,
|
||||
4000,
|
||||
"2026-09-30T12:00:00",
|
||||
);
|
||||
p.kind = MediaKind::Raw;
|
||||
p.file_size = 25_000_000 + i;
|
||||
p.captured = Some(format!("2026-09-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
|
||||
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
|
||||
p.meta.camera = "Camera Model X".into();
|
||||
p.meta.lens = "24-70mm f/2.8".into();
|
||||
p.meta.focal_mm = Some(35.0);
|
||||
p.meta.aperture = Some(4.0);
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.as_shot_wb = Some((5200.0, 3.0));
|
||||
p.local = !i.is_multiple_of(10);
|
||||
if p.local {
|
||||
// as a browse catalogues it: import defaults, a baseline fingerprint
|
||||
p.develop = Arc::new(p.import_defaults());
|
||||
p.set_local_baseline();
|
||||
}
|
||||
if !p.local {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = (i % 7) as f64 * 0.1;
|
||||
let s = Arc::new(d);
|
||||
p.develop = s.clone();
|
||||
p.edited = Some("2026-09-30T12:00:00".into());
|
||||
p.history = vec![HistoryStep { label: "Exposure".into(), settings: s }];
|
||||
p.rating = (i % 6) as u8;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn stats(mut v: Vec<f64>) -> String {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let pick = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
||||
format!("median {:.2} ms, p95 {:.2} ms, max {:.2} ms", pick(0.5), pick(0.95), pick(1.0))
|
||||
}
|
||||
|
||||
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let dir = std::path::PathBuf::from(args.next().ok_or("usage: persist_bench <scratch dir> [photos…]")?);
|
||||
let mut sizes: Vec<u64> = args.map(|a| a.parse()).collect::<std::result::Result<_, _>>()?;
|
||||
if sizes.is_empty() {
|
||||
sizes = vec![1000, 10_000, 85_000];
|
||||
}
|
||||
for n in sizes {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, _, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
let mut cat = Catalog::new();
|
||||
// bulk-add (as one import would) and log it, so the first compaction is an import's
|
||||
for chunk in (0..n).collect::<Vec<_>>().chunks(500) {
|
||||
let ops: Vec<Op> = chunk.iter().map(|i| Op::AddPhoto { photo: Box::new(photo(&mut cat, *i)) }).collect();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let first = j.stats().last_snapshot;
|
||||
println!("== {n} photos ({} local): snapshot {:.1} MB", cat.photos().filter(|p| p.local).count(), first.bytes as f64 / 1e6);
|
||||
|
||||
let t = Instant::now();
|
||||
let copy = cat.clone();
|
||||
println!(" catalog clone: {:.2} ms", ms(t));
|
||||
drop(copy);
|
||||
|
||||
// single-op appends (a rating change each), as commands do
|
||||
let ids: Vec<PhotoId> = cat.photos().map(|p| p.id).take(1000).collect();
|
||||
let mut appends = Vec::new();
|
||||
for k in 0..300usize {
|
||||
let id = ids.get(k % ids.len().max(1)).copied().unwrap_or(PhotoId(1));
|
||||
let op = Op::SetRating { id, rating: (k % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(std::slice::from_ref(&op))?;
|
||||
appends.push(j.stats().last_append_ms);
|
||||
}
|
||||
println!(" append (1 op, fsync): {}", stats(appends));
|
||||
|
||||
// compactions at the default threshold crossing
|
||||
let mut totals = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" compaction ({} records): serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms",
|
||||
s.records, s.serialize_ms, s.write_sync_ms, s.reset_ms, s.total_ms
|
||||
);
|
||||
totals.push(s.total_ms);
|
||||
}
|
||||
println!(" compaction total: {}", stats(totals));
|
||||
|
||||
// the same at the threshold, written by the worker while single-op appends go on
|
||||
let mut blocking = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.snapshot_in_background(&cat)?;
|
||||
let start_ms = ms(t);
|
||||
let mut during = Vec::new();
|
||||
while !j.snapshot_written() {
|
||||
let op = Op::SetRating { id: ids.get(1).copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
during.push(j.stats().last_append_ms);
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.poll()?;
|
||||
let finish_ms = ms(t);
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" background compaction ({} records): start {start_ms:.1} ms + finish {finish_ms:.1} ms blocking; worker {:.1} ms; {} appends during it: {}",
|
||||
s.records,
|
||||
s.total_ms,
|
||||
during.len(),
|
||||
stats(during)
|
||||
);
|
||||
blocking.push(s.blocking_ms);
|
||||
}
|
||||
println!(" background compaction blocking: {}", stats(blocking));
|
||||
|
||||
// forget the untouched Local records (folders last browsed long ago; every 7th folder
|
||||
// recently; every 50th Local record rated), then compact
|
||||
let folders: std::collections::BTreeSet<String> = cat.photos().filter(|p| p.local).filter_map(|p| folder_of(p)).collect();
|
||||
let mut ops: Vec<Op> = folders
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, f)| Op::SetBrowsed {
|
||||
folder: f.clone(),
|
||||
at: Some(if k % 7 == 0 { "2026-09-30T00:00:00" } else { "2026-06-01T00:00:00" }.into()),
|
||||
})
|
||||
.collect();
|
||||
ops.extend(cat.photos().filter(|p| p.local && p.id.0.is_multiple_of(50)).map(|p| Op::SetRating { id: p.id, rating: 2 }).collect::<Vec<_>>());
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
j.snapshot(&cat)?;
|
||||
let before = j.stats().last_snapshot.bytes;
|
||||
let t = Instant::now();
|
||||
let plan = cat.forget_local_plan("2026-10-05T00:00:00", DEFAULT_FORGET_DAYS, &|_| false);
|
||||
let plan_ms = ms(t);
|
||||
let t = Instant::now();
|
||||
let ops = plan.ops();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
let apply_ms = ms(t);
|
||||
j.snapshot(&cat)?;
|
||||
let after = j.stats().last_snapshot.bytes;
|
||||
println!(
|
||||
" forget Local: {} of {} local records forgotten (kept: {} recent, {} touched, {} in use); plan {plan_ms:.1} ms, apply+append {apply_ms:.1} ms; snapshot {:.1} MB -> {:.1} MB",
|
||||
plan.evict.len(),
|
||||
plan.local,
|
||||
plan.kept_recent,
|
||||
plan.kept_touched,
|
||||
plan.kept_in_use,
|
||||
before as f64 / 1e6,
|
||||
after as f64 / 1e6
|
||||
);
|
||||
|
||||
drop(j);
|
||||
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
|
||||
// peak memory of building + compacting only
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (_, loaded, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
println!(" open (load snapshot + replay): {:.1} ms", ms(t));
|
||||
if loaded.len() != cat.len() {
|
||||
return Err("reloaded catalog differs".into());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 0227748e171237a13833c9175fe02c7fe6ff332266798961a6a43ec37b81d1b4 # shrinks to actions = [(3, 0), (0, 0), (4, 0), (0, 0)]
|
||||
cc 0b0ab2531bd46fbe261b55ecffebea9d3e8949900ab7bc33fb27ca19f5cd7b8b # shrinks to actions = [(4, 443), (3, 474), (2, 27), (5, 858), (5, 466), (0, 901), (4, 907), (3, 839), (0, 630), (7, 578), (4, 13), (2, 361), (6, 136), (6, 12), (6, 223), (1, 394), (6, 78), (5, 837), (6, 765), (4, 207), (2, 185), (2, 583), (2, 56), (5, 430), (1, 702), (1, 547), (5, 552), (4, 762), (0, 443), (0, 851), (7, 240), (4, 438), (3, 599), (6, 70), (4, 694), (1, 327), (7, 859), (6, 814), (4, 336), (3, 438), (0, 131), (0, 831), (3, 18), (2, 963), (6, 10), (3, 114)]
|
||||
@@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String {
|
||||
|
||||
/// Header text for a group key.
|
||||
pub fn group_label(key: &str) -> String {
|
||||
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
|
||||
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
|
||||
// `get` rather than `[..]`: a non-ASCII key must not split a character
|
||||
let year = key.get(..4).unwrap_or(key);
|
||||
match key.len() {
|
||||
10 => match (weekday(key), month(key), key[8..10].parse::<u32>().ok()) {
|
||||
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]),
|
||||
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
|
||||
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
|
||||
_ => key.to_string(),
|
||||
},
|
||||
7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()),
|
||||
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
|
||||
4 => key.to_string(),
|
||||
_ => "Unknown Date".into(),
|
||||
}
|
||||
@@ -196,6 +198,9 @@ mod tests {
|
||||
assert_eq!(group_label("2026-02"), "February 2026");
|
||||
assert_eq!(group_label("2026"), "2026");
|
||||
assert_eq!(group_label(""), "Unknown Date");
|
||||
// non-ASCII keys of a date's length used to be sliced mid-character
|
||||
assert_eq!(group_label("2024-01é0"), "2024-01é0");
|
||||
assert_eq!(group_label("202é01"), "January 202é01");
|
||||
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
|
||||
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
|
||||
assert_eq!(shift_iso("nope", 5), None);
|
||||
|
||||
+485
-30
@@ -1,8 +1,9 @@
|
||||
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
|
||||
//!
|
||||
//! Files (in a [`Store`]):
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N`; replaced atomically (temp + fsync + rename).
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync +
|
||||
//! rename).
|
||||
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
|
||||
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
|
||||
//! fsynced before [`Journal::append`] returns.
|
||||
@@ -10,10 +11,55 @@
|
||||
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
|
||||
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
|
||||
//! and the file is cut back to the last good record, so later appends start on a clean line;
|
||||
//! - a **failed append** (disk full, a dropped network share) may leave part of its batch in the
|
||||
//! file: [`Journal::append`] cuts the log back to the last whole record before returning the
|
||||
//! error (or, if that fails too, before the next append), so the retry — the same ops with the
|
||||
//! same `seq`s — starts on a clean line. Logs written by builds without that cut-back hold the
|
||||
//! fragment glued to the first retried record on one line; the loader recognises the whole,
|
||||
//! CRC-valid record at the end of such a line and skips only the fragment;
|
||||
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
|
||||
//! the log) are skipped;
|
||||
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
|
||||
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
|
||||
//!
|
||||
//! **Background compaction** ([`Journal::snapshot_in_background`], when the store offers a
|
||||
//! [`Store::background_writer`]): a worker thread writes the snapshot of a copy of the catalog at
|
||||
//! op `N` while appends go on (durably, as always) to the same log. Once the snapshot is durable,
|
||||
//! [`Journal::poll`] atomically replaces the log with the records appended since the snapshot
|
||||
//! started (`seq > N`, kept in memory). Every crash point is covered by the rules above:
|
||||
//! - before the rename of `catalog.snap`: the old snapshot + the whole log (nothing was removed);
|
||||
//! - between the rename and the log rewrite: the new snapshot + the whole log, whose records
|
||||
//! `<= N` are skipped as stale;
|
||||
//! - after the log rewrite: the new snapshot + exactly the records `> N`.
|
||||
//!
|
||||
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
|
||||
//! first wait for it, so an older snapshot can never replace a newer one.
|
||||
//!
|
||||
//! # Format versions
|
||||
//!
|
||||
//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has
|
||||
//! no header of its own, and is only ever appended to under a snapshot of this build's format.
|
||||
//!
|
||||
//! | Version | Written by | Adds |
|
||||
//! |---|---|---|
|
||||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 1 | up to v0.2.0 | |
|
||||
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older
|
||||
//! build would misread or silently drop. New fields must deserialize from older data
|
||||
//! (`#[serde(default)]`), so newer builds keep reading every older format.
|
||||
//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a
|
||||
//! snapshot in this build's format (also for a new library), before anything is appended — so a
|
||||
//! library is never a mix, and older builds refuse it from then on instead of reading part of it.
|
||||
//! [`LoadReport::upgraded_from`] says when that happened.
|
||||
//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`],
|
||||
//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't
|
||||
//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it
|
||||
//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with
|
||||
//! "unsupported format".)
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::{Catalog, CatalogError, Op, Result};
|
||||
@@ -21,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result};
|
||||
pub const SNAPSHOT: &str = "catalog.snap";
|
||||
pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
const VERSION: u32 = 1;
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 2;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -49,10 +97,75 @@ pub struct LoadReport {
|
||||
pub failed: usize,
|
||||
/// Bytes of a torn final record that were dropped.
|
||||
pub torn_bytes: u64,
|
||||
/// Partial records left mid-log by a failed append that was retried (written by builds that
|
||||
/// didn't cut the log back after a failed write): skipped, the retried records were replayed.
|
||||
pub torn_fragments: usize,
|
||||
/// The damaged log was preserved under this name.
|
||||
pub damaged: Option<String>,
|
||||
/// No catalog files existed (a new library).
|
||||
pub created: bool,
|
||||
/// The library was in an older catalog format (`0` = before format versions: a log without a
|
||||
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
|
||||
pub upgraded_from: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
/// per write under `LIGHTCRAFT_PROFILE`). Times are wall-clock milliseconds on the calling
|
||||
/// thread, i.e. how long the caller (the UI thread, for the app) was blocked.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersistStats {
|
||||
/// [`Journal::append`] calls that wrote something.
|
||||
pub appends: u64,
|
||||
/// Encode + write + `sync_data` of the last / slowest append.
|
||||
pub last_append_ms: f64,
|
||||
pub max_append_ms: f64,
|
||||
/// Snapshots written (compactions, plus the ones on close / repair).
|
||||
pub snapshots: u64,
|
||||
/// The last snapshot, by stage.
|
||||
pub last_snapshot: SnapshotTiming,
|
||||
/// Total time of the slowest snapshot.
|
||||
pub max_snapshot_ms: f64,
|
||||
/// Longest time a snapshot blocked the caller (for background snapshots: copying the catalog
|
||||
/// and starting the worker, plus rewriting the log when it is done).
|
||||
pub max_blocking_ms: f64,
|
||||
/// A background snapshot is being written.
|
||||
pub snapshot_running: bool,
|
||||
/// Background snapshots that failed (the log was kept whole; retried later).
|
||||
pub failed_snapshots: u64,
|
||||
}
|
||||
|
||||
/// One snapshot (compaction), by stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnapshotTiming {
|
||||
/// Serialising the catalog to JSON, streamed into the temp file (buffered writes included).
|
||||
pub serialize_ms: f64,
|
||||
/// Flushing, `sync_all` and the atomic rename of `catalog.snap`.
|
||||
pub write_sync_ms: f64,
|
||||
/// Resetting `catalog.log` (an atomic rewrite to empty, or to the records appended while a
|
||||
/// background snapshot ran; fsynced).
|
||||
pub reset_ms: f64,
|
||||
/// Start to finish (for a background snapshot: until [`Journal::poll`] saw it done).
|
||||
pub total_ms: f64,
|
||||
/// How long the caller was blocked (equal to `total_ms` unless `background`).
|
||||
pub blocking_ms: f64,
|
||||
/// Written by a worker thread.
|
||||
pub background: bool,
|
||||
/// Size of `catalog.snap`.
|
||||
pub bytes: u64,
|
||||
/// Log records the snapshot compacted.
|
||||
pub records: u64,
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print persistence timings to stderr.
|
||||
fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
||||
fn ms_since(t: web_time::Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
pub struct Journal {
|
||||
@@ -63,6 +176,29 @@ pub struct Journal {
|
||||
log_records: u64,
|
||||
log_bytes: u64,
|
||||
pub policy: SnapshotPolicy,
|
||||
stats: PersistStats,
|
||||
/// The background snapshot in flight.
|
||||
pending: Option<Pending>,
|
||||
/// After a failed background snapshot: don't retry before the log has this many records.
|
||||
retry_at_records: u64,
|
||||
/// A failed append may have left part of its records after `log_bytes`, and cutting them off
|
||||
/// failed too: the next append cuts the log back to `log_bytes` first.
|
||||
tail_dirty: bool,
|
||||
}
|
||||
|
||||
/// A background snapshot in flight.
|
||||
struct Pending {
|
||||
/// The snapshot holds the state after this op.
|
||||
seq: u64,
|
||||
/// Log bytes / records appended since it started (all `> seq`): the log once it's done.
|
||||
tail: Vec<u8>,
|
||||
tail_records: u64,
|
||||
/// Log records it compacts.
|
||||
records: u64,
|
||||
started: web_time::Instant,
|
||||
/// Time the caller spent starting it.
|
||||
start_ms: f64,
|
||||
worker: std::thread::JoinHandle<std::io::Result<SnapshotTiming>>,
|
||||
}
|
||||
|
||||
fn io(e: std::io::Error) -> CatalogError {
|
||||
@@ -76,24 +212,93 @@ pub fn encode_record(seq: u64, op: &Op) -> String {
|
||||
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
|
||||
}
|
||||
|
||||
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
|
||||
/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or
|
||||
/// the op is one this version doesn't know (see [`Journal::open`]).
|
||||
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
let seq: u64 = seq.parse().ok()?;
|
||||
let crc: u32 = crc.parse().ok()?;
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return None;
|
||||
match decode_line(line) {
|
||||
Line::Record(seq, op) => Some((seq, op)),
|
||||
Line::Newer(_) | Line::Bad => None,
|
||||
}
|
||||
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||
}
|
||||
|
||||
/// One log line, decoded.
|
||||
enum Line {
|
||||
Record(u64, Op),
|
||||
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
|
||||
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
|
||||
Newer(u64),
|
||||
/// Malformed or a CRC mismatch: torn or damaged.
|
||||
Bad,
|
||||
}
|
||||
|
||||
fn decode_line(line: &str) -> Line {
|
||||
let parts = || {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
Some((seq.parse::<u64>().ok()?, crc.parse::<u32>().ok()?, body))
|
||||
};
|
||||
let Some((seq, crc, body)) = parts() else { return Line::Bad };
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return Line::Bad;
|
||||
}
|
||||
match serde_json::from_str(body) {
|
||||
Ok(op) => Line::Record(seq, op),
|
||||
Err(_) => Line::Newer(seq),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a library written by a newer LightCraft.
|
||||
fn newer(what: String) -> CatalogError {
|
||||
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
|
||||
}
|
||||
|
||||
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
|
||||
/// by the record that retried it (the retry re-encodes the same ops with the same `seq`s). Returns
|
||||
/// the fragment's length and the record, if that record continues the log (`seq <= last + 1`).
|
||||
/// The record's CRC must match, so damage is never mistaken for it.
|
||||
fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
|
||||
const START: &[u8] = b"{\"seq\":";
|
||||
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
|
||||
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
|
||||
(rec.0 <= last + 1).then_some((i, rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `catalog.snap` for the state after op `seq`, streaming the JSON into the store (no
|
||||
/// whole-file string). The bytes are exactly
|
||||
/// `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":<serde_json of the catalog>}\n`,
|
||||
/// as before streaming. Fills in the serialise / write+sync times and the size.
|
||||
fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io::Result<SnapshotTiming> {
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut serialize_ms = 0.0;
|
||||
let bytes = store.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
use std::io::Write;
|
||||
let t = web_time::Instant::now();
|
||||
// serde_json emits many tiny writes: buffer them in a concrete writer (inlined), so only
|
||||
// 1 MiB chunks go through the store's `dyn Write`
|
||||
let mut b = std::io::BufWriter::with_capacity(1 << 20, w);
|
||||
write!(b, "{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{seq},\"catalog\":")?;
|
||||
serde_json::to_writer(&mut b, catalog).map_err(std::io::Error::other)?;
|
||||
b.write_all(b"}\n")?;
|
||||
b.flush()?;
|
||||
serialize_ms = ms_since(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Just the identification of `catalog.snap` (read before the catalog itself).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
struct SnapHeader {
|
||||
format: String,
|
||||
version: u32,
|
||||
}
|
||||
|
||||
/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
seq: u64,
|
||||
catalog: Catalog,
|
||||
}
|
||||
@@ -106,18 +311,34 @@ impl Journal {
|
||||
let snap = store.read(SNAPSHOT).map_err(io)?;
|
||||
let log = store.read(LOG).map_err(io)?;
|
||||
report.created = snap.is_none() && log.is_none();
|
||||
let (mut catalog, snapshot_seq) = match snap {
|
||||
let (mut catalog, snapshot_seq, snapshot_version) = match snap {
|
||||
Some(bytes) => {
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if s.format != FORMAT || s.version > VERSION {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
|
||||
// the header first: a newer snapshot may not parse as this version's catalog
|
||||
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if h.format != FORMAT {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
|
||||
}
|
||||
(s.catalog, s.seq)
|
||||
if h.version > VERSION {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
}
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
(s.catalog, s.seq, Some(h.version))
|
||||
}
|
||||
None => (Catalog::new(), 0),
|
||||
None => (Catalog::new(), 0, None),
|
||||
};
|
||||
report.snapshot_seq = snapshot_seq;
|
||||
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
|
||||
let mut j = Journal {
|
||||
store,
|
||||
seq: snapshot_seq,
|
||||
snapshot_seq,
|
||||
log_records: 0,
|
||||
log_bytes: 0,
|
||||
policy: SnapshotPolicy::default(),
|
||||
stats: PersistStats::default(),
|
||||
pending: None,
|
||||
retry_at_records: 0,
|
||||
tail_dirty: false,
|
||||
};
|
||||
let log = log.unwrap_or_default();
|
||||
|
||||
// Scan records; `good_end` is the byte offset just past the last good record.
|
||||
@@ -136,7 +357,22 @@ impl Journal {
|
||||
good_end = next;
|
||||
continue;
|
||||
}
|
||||
match line.and_then(decode_record) {
|
||||
let mut record = line.and_then(decode_record);
|
||||
if record.is_none()
|
||||
&& let Some((at, r)) = resync(&log[pos..line_end], j.seq)
|
||||
{
|
||||
// the torn start of a failed append, then the retried records (older builds
|
||||
// appended them right after the fragment)
|
||||
log::warn!("catalog log: skipped a partial record ({at} bytes) left by a failed write");
|
||||
report.torn_fragments += 1;
|
||||
record = Some(r);
|
||||
}
|
||||
if record.is_none()
|
||||
&& let Some(Line::Newer(seq)) = line.map(decode_line)
|
||||
{
|
||||
return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know")));
|
||||
}
|
||||
match record {
|
||||
Some((seq, op)) if seq <= j.seq => {
|
||||
// already in the snapshot
|
||||
let _ = op;
|
||||
@@ -152,7 +388,15 @@ impl Journal {
|
||||
}
|
||||
_ => {
|
||||
// bad record (or a gap): torn tail if nothing good follows, else damage
|
||||
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
|
||||
let mut rest_has_good = false;
|
||||
for l in log[next..].split(|b| *b == b'\n') {
|
||||
match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) {
|
||||
Ok(Line::Record(..)) => rest_has_good = true,
|
||||
// refused before anything is modified
|
||||
Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))),
|
||||
Ok(Line::Bad) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
if rest_has_good {
|
||||
damaged_at = Some(pos);
|
||||
}
|
||||
@@ -176,10 +420,34 @@ impl Journal {
|
||||
report.torn_bytes = (log.len() - good_end) as u64;
|
||||
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
|
||||
j.store.truncate(LOG, good_end as u64).map_err(io)?;
|
||||
j.log_bytes = good_end as u64;
|
||||
} else if needs_newline {
|
||||
j.store.append(LOG, b"\n").map_err(io)?;
|
||||
j.log_bytes = good_end as u64 + 1;
|
||||
} else {
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
}
|
||||
// Bring an older (or new, or snapshot-less) library to this version's format before
|
||||
// anything is appended: a snapshot in this format makes older builds refuse the library
|
||||
// instead of misreading ops or dropping fields they don't know.
|
||||
if report.created {
|
||||
// tiny: written directly, not counted as a compaction
|
||||
let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot());
|
||||
if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) {
|
||||
log::warn!("catalog: can't write the new library's snapshot: {e}");
|
||||
}
|
||||
} else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
|
||||
report.upgraded_from = Some(snapshot_version.unwrap_or(0));
|
||||
match j.snapshot(&catalog) {
|
||||
Ok(()) => {
|
||||
if let Some(v) = report.upgraded_from {
|
||||
log::info!("catalog: upgraded from format v{v} to v{VERSION}");
|
||||
}
|
||||
}
|
||||
// nothing lost: the old files are intact and still load; retried on next open
|
||||
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
|
||||
}
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
catalog.revision = 0;
|
||||
Ok((j, catalog, report))
|
||||
@@ -190,6 +458,7 @@ impl Journal {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut buf = String::new();
|
||||
let mut seq = self.seq;
|
||||
for op in ops {
|
||||
@@ -197,29 +466,202 @@ impl Journal {
|
||||
buf.push_str(&encode_record(seq, op));
|
||||
buf.push('\n');
|
||||
}
|
||||
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
||||
if self.tail_dirty {
|
||||
self.store.truncate(LOG, self.log_bytes).map_err(io)?;
|
||||
self.tail_dirty = false;
|
||||
}
|
||||
if let Err(e) = self.store.append(LOG, buf.as_bytes()) {
|
||||
// Part of the batch may be in the file. Cut it back to the last whole record, so the
|
||||
// retry (same seqs) starts on a clean line instead of after a fragment that would
|
||||
// read back as damage.
|
||||
if let Err(t) = self.store.truncate(LOG, self.log_bytes) {
|
||||
log::error!("catalog: can't cut the log back after a failed append: {t}");
|
||||
self.tail_dirty = true;
|
||||
}
|
||||
return Err(io(e));
|
||||
}
|
||||
if let Some(p) = self.pending.as_mut() {
|
||||
p.tail.extend_from_slice(buf.as_bytes());
|
||||
p.tail_records += ops.len() as u64;
|
||||
}
|
||||
self.seq = seq;
|
||||
self.log_records += ops.len() as u64;
|
||||
self.log_bytes += buf.len() as u64;
|
||||
let ms = ms_since(t0);
|
||||
self.stats.appends += 1;
|
||||
self.stats.last_append_ms = ms;
|
||||
self.stats.max_append_ms = self.stats.max_append_ms.max(ms);
|
||||
if profiling() {
|
||||
eprintln!("catalog: append {} op(s), {} B: {ms:.2} ms", ops.len(), buf.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
|
||||
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
|
||||
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
|
||||
self.snapshot_with_unlogged(catalog, 0)
|
||||
}
|
||||
|
||||
/// [`Journal::snapshot`] of a catalog that also reflects `unlogged` ops applied after the
|
||||
/// last appended one but never written to the log (their append failed). The snapshot counts
|
||||
/// them (it holds the state after op `seq + unlogged`), so they are saved and must not be
|
||||
/// appended afterwards. Once the snapshot is durable [`Journal::seq`] includes them, even if
|
||||
/// resetting the log then fails (the error is still returned).
|
||||
pub fn snapshot_with_unlogged(&mut self, catalog: &Catalog, unlogged: u64) -> Result<()> {
|
||||
// never two snapshot writers; the one in flight is older, so it must land first
|
||||
if let Err(e) = self.wait() {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let seq = self.seq + unlogged;
|
||||
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// nothing lost (the log is whole); don't retry on every append
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// the snapshot is durable: it holds every op up to `seq`
|
||||
self.seq = seq;
|
||||
self.snapshot_seq = seq;
|
||||
let t2 = web_time::Instant::now();
|
||||
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||
self.snapshot_seq = self.seq;
|
||||
timing.reset_ms = ms_since(t2);
|
||||
timing.total_ms = ms_since(t0);
|
||||
timing.blocking_ms = timing.total_ms;
|
||||
timing.records = self.log_records;
|
||||
self.record_snapshot(timing);
|
||||
self.log_records = 0;
|
||||
self.log_bytes = 0;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact like [`Journal::snapshot`], but write the snapshot on a worker thread from a copy
|
||||
/// of `catalog` (cheap: photos are shared) when the store supports it (else synchronously).
|
||||
/// `catalog` must reflect every appended op. Appends go on meanwhile; [`Journal::poll`]
|
||||
/// finishes the compaction. No-op while a snapshot is already in flight.
|
||||
pub fn snapshot_in_background(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
if self.pending.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let Some(mut writer) = self.store.background_writer() else { return self.snapshot(catalog) };
|
||||
let copy = catalog.clone();
|
||||
let seq = self.seq;
|
||||
let spawned = std::thread::Builder::new().name("catalog-snapshot".into()).spawn(move || write_snapshot(writer.as_mut(), seq, ©));
|
||||
let worker = match spawned {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("catalog: can't start a snapshot thread ({e}); writing it now");
|
||||
return self.snapshot(catalog);
|
||||
}
|
||||
};
|
||||
self.pending =
|
||||
Some(Pending { seq, tail: Vec::new(), tail_records: 0, records: self.log_records, started: t0, start_ms: ms_since(t0), worker });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish a background snapshot if its worker is done (cheap otherwise). Returns whether one
|
||||
/// finished. An error means the snapshot failed: nothing was lost (the log is whole), and
|
||||
/// compaction is retried once the log has grown by another [`SnapshotPolicy::max_records`].
|
||||
pub fn poll(&mut self) -> Result<bool> {
|
||||
if !self.pending.as_ref().is_some_and(|p| p.worker.is_finished()) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.finish().map(|()| true)
|
||||
}
|
||||
|
||||
/// Wait for the background snapshot in flight, if any, and finish it.
|
||||
pub fn wait(&mut self) -> Result<()> {
|
||||
if self.pending.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.finish()
|
||||
}
|
||||
|
||||
/// A background snapshot is in flight (until [`Journal::poll`] or [`Journal::wait`] finish it).
|
||||
pub fn snapshot_running(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
/// The background worker is done (its result not yet applied by [`Journal::poll`]).
|
||||
pub fn snapshot_written(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|p| p.worker.is_finished())
|
||||
}
|
||||
|
||||
/// Join the worker; on success replace the log with the records appended meanwhile.
|
||||
fn finish(&mut self) -> Result<()> {
|
||||
let Some(p) = self.pending.take() else { return Ok(()) };
|
||||
let t0 = web_time::Instant::now();
|
||||
let written = p.worker.join().unwrap_or_else(|_| Err(std::io::Error::other("snapshot thread panicked")));
|
||||
let mut timing = match written {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// the log still holds every record: nothing lost, retry later
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// `catalog.snap` (op `p.seq`) is durable; the log's records `<= p.seq` are now stale.
|
||||
// Keep exactly the ones appended since (a crash before this rewrite skips the stale ones).
|
||||
let t1 = web_time::Instant::now();
|
||||
self.snapshot_seq = p.seq;
|
||||
if let Err(e) = self.store.write_atomic(LOG, &p.tail) {
|
||||
// the log stays whole (stale records are skipped on load); compact again later
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: can't trim the log after a snapshot: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
timing.reset_ms = ms_since(t1);
|
||||
timing.total_ms = ms_since(p.started);
|
||||
timing.blocking_ms = p.start_ms + ms_since(t0);
|
||||
timing.background = true;
|
||||
timing.records = p.records;
|
||||
self.record_snapshot(timing);
|
||||
self.log_records = p.tail_records;
|
||||
self.log_bytes = p.tail.len() as u64;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_snapshot(&mut self, t: SnapshotTiming) {
|
||||
self.stats.snapshots += 1;
|
||||
self.stats.last_snapshot = t;
|
||||
self.stats.max_snapshot_ms = self.stats.max_snapshot_ms.max(t.total_ms);
|
||||
self.stats.max_blocking_ms = self.stats.max_blocking_ms.max(t.blocking_ms);
|
||||
if profiling() {
|
||||
eprintln!(
|
||||
"catalog: {}snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms, blocking {:.1} ms",
|
||||
if t.background { "background " } else { "" },
|
||||
t.records,
|
||||
t.bytes,
|
||||
t.serialize_ms,
|
||||
t.write_sync_ms,
|
||||
t.reset_ms,
|
||||
t.total_ms,
|
||||
t.blocking_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where persistence time went so far.
|
||||
pub fn stats(&self) -> PersistStats {
|
||||
PersistStats { snapshot_running: self.pending.is_some(), ..self.stats }
|
||||
}
|
||||
|
||||
/// The log is long enough to be worth compacting.
|
||||
pub fn wants_snapshot(&self) -> bool {
|
||||
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
|
||||
self.pending.is_none()
|
||||
&& self.log_records >= self.retry_at_records
|
||||
&& (self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes)
|
||||
}
|
||||
|
||||
pub fn seq(&self) -> u64 {
|
||||
@@ -238,3 +680,16 @@ impl Journal {
|
||||
self.store.describe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Journal {
|
||||
/// Wait for a background snapshot (never leave a writer behind that could replace a newer
|
||||
/// snapshot of a journal reopened on the same files). The log is left whole: the snapshot's
|
||||
/// records in it are skipped as stale on the next load.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.pending.take()
|
||||
&& let Ok(Err(e)) = p.worker.join()
|
||||
{
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,23 @@
|
||||
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
|
||||
//! - **undo/redo**: the engine keeps inverse ops;
|
||||
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
||||
//!
|
||||
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
|
||||
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
|
||||
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
|
||||
//! instead of reading part of it.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dates;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
pub mod lock;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
pub mod safe_file;
|
||||
pub mod stacks;
|
||||
pub mod store;
|
||||
|
||||
@@ -20,11 +29,13 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use journal::{Journal, LoadReport, SnapshotPolicy};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
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};
|
||||
@@ -43,6 +54,10 @@ pub enum CatalogError {
|
||||
Corrupt(String),
|
||||
#[error("catalog storage: {0}")]
|
||||
Io(String),
|
||||
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
|
||||
/// version doesn't know). Nothing was read into the session and nothing was modified.
|
||||
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
|
||||
Newer(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CatalogError>;
|
||||
@@ -179,12 +194,22 @@ pub enum Op {
|
||||
file_size: u64,
|
||||
#[serde(default)]
|
||||
content_hash: Option<String>,
|
||||
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
|
||||
/// `None` = its raw data decodes (or it isn't a raw).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
preview_only: Option<String>,
|
||||
},
|
||||
/// The name shown for a colour label (`None` = its colour's name).
|
||||
SetLabelName {
|
||||
label: ColorLabel,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// When a Local folder was last browsed (ISO 8601; `None` = forget the time). Not an undo
|
||||
/// step: it drives forgetting untouched Local records (see [`local`]).
|
||||
SetBrowsed {
|
||||
folder: String,
|
||||
at: Option<String>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
@@ -204,6 +229,9 @@ pub struct Catalog {
|
||||
/// Custom colour label names.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
label_names: BTreeMap<ColorLabel, String>,
|
||||
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
browsed: BTreeMap<String, String>,
|
||||
/// Increments on every applied op.
|
||||
#[serde(skip)]
|
||||
pub revision: u64,
|
||||
@@ -285,7 +313,8 @@ impl Catalog {
|
||||
/// Number of photos shown for an album in the sources list (excludes deleted photos).
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
|
||||
// counted in place: no id list is built just for its length
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
@@ -514,7 +543,7 @@ impl Catalog {
|
||||
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
|
||||
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
|
||||
}
|
||||
Op::SetContent { id, width, height, file_size, content_hash } => {
|
||||
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetContent {
|
||||
id,
|
||||
@@ -522,6 +551,7 @@ impl Catalog {
|
||||
height: std::mem::replace(&mut p.height, height),
|
||||
file_size: std::mem::replace(&mut p.file_size, file_size),
|
||||
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
|
||||
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
|
||||
}
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
@@ -540,6 +570,14 @@ impl Catalog {
|
||||
};
|
||||
Op::SetLabelName { label, name: old }
|
||||
}
|
||||
Op::SetBrowsed { folder, at } => {
|
||||
let folder = crate::query::folder_key(&folder);
|
||||
let old = match at {
|
||||
Some(t) => self.browsed.insert(folder.clone(), t),
|
||||
None => self.browsed.remove(&folder),
|
||||
};
|
||||
Op::SetBrowsed { folder, at: old }
|
||||
}
|
||||
Op::Batch { ops } => {
|
||||
let mut inverses = Vec::with_capacity(ops.len());
|
||||
for op in ops {
|
||||
@@ -615,4 +653,14 @@ impl Catalog {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
#[cfg(test)]
|
||||
mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Forgetting untouched Local browse records.
|
||||
//!
|
||||
//! Browsing a folder (Local) catalogues its photos as `local` records so they can be shown,
|
||||
//! rated and edited in place. Most are only ever looked at, and on a large disk they can be most
|
||||
//! of the catalog. A record is **forgotten** (removed from the catalog, never from the disk —
|
||||
//! files and XMP sidecars are not touched) when all of these hold:
|
||||
//!
|
||||
//! - it is a Local record ([`Photo::local`]) of a file, not deleted, not a virtual copy;
|
||||
//! - its folder was last browsed more than `days` ago ([`Catalog::last_browsed`]). A folder with
|
||||
//! no recorded time (a catalog from before this existed) counts as browsed *now*: it is stamped
|
||||
//! with the current time and nothing in it is forgotten this time;
|
||||
//! - it is **untouched** ([`Photo::untouched_local`]): nothing about it changed since it was
|
||||
//! browsed — see below;
|
||||
//! - nothing refers to it: no album holds it or shows it as cover, no stack holds it, no virtual
|
||||
//! copy was made of it; and the caller (the engine) says it isn't in use (visible, selected,
|
||||
//! in the undo history, has a smart preview…).
|
||||
//!
|
||||
//! **Untouched.** A record catalogued by a browse carries a fingerprint of its whole state at
|
||||
//! that moment ([`Photo::local_baseline`]). It is untouched while its state still hashes to that
|
||||
//! fingerprint, i.e. *any* change since — develop settings or history, versions, rating, flag,
|
||||
//! label, any metadata (title, caption, keywords, location, copyright…), capture time, crop,
|
||||
//! rename or relink, culling scores, file content — keeps it. Records without a fingerprint
|
||||
//! (browsed before fingerprints existed) are judged by their data alone and kept unless every
|
||||
//! user-editable field is still empty / default: no rating, flag or label; develop settings equal
|
||||
//! to the import defaults with no history, versions or edit time; no title, caption, alt text,
|
||||
//! description, keywords, creator, copyright, rights, location or GPS; no culling scores.
|
||||
//! When in doubt a record is kept.
|
||||
//!
|
||||
//! Browsing the folder again catalogues its files afresh (new records, new fingerprints).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Catalog, Op, Photo, PhotoId, Source};
|
||||
|
||||
/// Default for how long a Local folder may go unbrowsed before its untouched records are
|
||||
/// forgotten (days).
|
||||
pub const DEFAULT_FORGET_DAYS: u32 = 30;
|
||||
|
||||
/// Records removed per logged batch (keeps log lines a reasonable size).
|
||||
const BATCH: usize = 2000;
|
||||
|
||||
/// 64-bit FNV-1a over everything written to it (stable across builds and platforms, unlike
|
||||
/// `std`'s hasher), so a photo is hashed as it serialises, without a buffer.
|
||||
struct Fnv(u64);
|
||||
|
||||
impl std::io::Write for Fnv {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for b in buf {
|
||||
self.0 ^= u64::from(*b);
|
||||
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a file-backed photo is in (`None` for demo photos).
|
||||
pub fn folder_of(p: &Photo) -> Option<String> {
|
||||
match &p.source {
|
||||
// `/` and `\` are both separators whatever the platform (a catalog can move between them)
|
||||
Source::File { path } => {
|
||||
let key = crate::query::folder_key(path);
|
||||
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() })
|
||||
}
|
||||
Source::Demo { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
/// A hash of everything about the photo except [`Photo::local_baseline`] itself.
|
||||
pub fn fingerprint(&self) -> u64 {
|
||||
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
|
||||
let r = if self.local_baseline.is_some() {
|
||||
let mut p = self.clone();
|
||||
p.local_baseline = None;
|
||||
serde_json::to_writer(&mut h, &p)
|
||||
} else {
|
||||
serde_json::to_writer(&mut h, self)
|
||||
};
|
||||
// (serialising a photo can't fail; if it ever did, no baseline would match: kept)
|
||||
if r.is_err() { 0 } else { h.0 }
|
||||
}
|
||||
|
||||
/// Record the current state as the browse baseline (done when a browse catalogues it).
|
||||
pub fn set_local_baseline(&mut self) {
|
||||
self.local_baseline = None;
|
||||
self.local_baseline = Some(self.fingerprint());
|
||||
}
|
||||
|
||||
/// A Local record nothing was changed on since it was browsed (see the module docs). Says
|
||||
/// nothing about albums, stacks or virtual copies referring to it.
|
||||
pub fn untouched_local(&self) -> bool {
|
||||
if !self.local || self.deleted || self.copy_of.is_some() || self.copy_name.is_some() || folder_of(self).is_none() {
|
||||
return false;
|
||||
}
|
||||
match self.local_baseline {
|
||||
Some(b) => self.fingerprint() == b,
|
||||
None => self.looks_unedited(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every user-editable field is still empty / default (records without a baseline).
|
||||
fn looks_unedited(&self) -> bool {
|
||||
let m = &self.meta;
|
||||
self.rating == 0
|
||||
&& self.flag == crate::Flag::None
|
||||
&& self.label.is_none()
|
||||
&& self.edited.is_none()
|
||||
&& self.history.is_empty()
|
||||
&& self.versions.is_empty()
|
||||
&& *self.develop == self.import_defaults()
|
||||
&& self.analysis.is_none()
|
||||
&& m.title.is_empty()
|
||||
&& m.caption.is_empty()
|
||||
&& m.alt_text.is_empty()
|
||||
&& m.extended_description.is_empty()
|
||||
&& m.keywords.is_empty()
|
||||
&& m.creator.is_empty()
|
||||
&& m.copyright.is_empty()
|
||||
&& m.copyright_status.is_unknown()
|
||||
&& m.usage_terms.is_empty()
|
||||
&& m.copyright_url.is_empty()
|
||||
&& m.location.is_empty()
|
||||
&& m.city.is_empty()
|
||||
&& m.state.is_empty()
|
||||
&& m.country.is_empty()
|
||||
&& m.gps.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// What forgetting Local records would do / did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForgetPlan {
|
||||
/// Local records looked at.
|
||||
pub local: usize,
|
||||
/// Records to forget.
|
||||
pub evict: Vec<PhotoId>,
|
||||
/// Kept: their folder was browsed within the period (or has no time yet).
|
||||
pub kept_recent: usize,
|
||||
/// Kept: changed by the user since they were browsed.
|
||||
pub kept_touched: usize,
|
||||
/// Kept: in an album, a stack, the source of a virtual copy, or in use.
|
||||
pub kept_in_use: usize,
|
||||
/// Folders with Local records but no last-browsed time: stamped now.
|
||||
pub stamp: Vec<String>,
|
||||
/// Folders whose last Local record goes: their time is dropped.
|
||||
pub unstamp: Vec<String>,
|
||||
/// The time stamps are given.
|
||||
#[serde(skip)]
|
||||
pub now: String,
|
||||
}
|
||||
|
||||
impl ForgetPlan {
|
||||
/// The ops that carry the plan out (empty when there is nothing to do).
|
||||
pub fn ops(&self) -> Vec<Op> {
|
||||
let mut ops: Vec<Op> = self.stamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: Some(self.now.clone()) }).collect();
|
||||
ops.extend(self.evict.chunks(BATCH).map(|c| Op::Batch { ops: c.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }));
|
||||
ops.extend(self.unstamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: None }));
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// When `folder` was last browsed (ISO 8601), if known.
|
||||
pub fn last_browsed(&self, folder: &str) -> Option<&str> {
|
||||
self.browsed.get(&crate::query::folder_key(folder)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Folders with a last-browsed time.
|
||||
pub fn browsed_folders(&self) -> &BTreeMap<String, String> {
|
||||
&self.browsed
|
||||
}
|
||||
|
||||
/// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of
|
||||
/// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an
|
||||
/// unreadable `now` forgets nothing. See the module docs.
|
||||
pub fn forget_local_plan(&self, now: &str, days: u32, in_use: &dyn Fn(&Photo) -> bool) -> ForgetPlan {
|
||||
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
|
||||
let now_s = crate::stacks::iso_seconds(now);
|
||||
let max_age = i64::from(days) * 86_400;
|
||||
let mut referenced: HashSet<PhotoId> = HashSet::new();
|
||||
for a in self.albums.values() {
|
||||
referenced.extend(a.photos.iter().copied());
|
||||
referenced.extend(a.cover);
|
||||
}
|
||||
for s in self.stacks.values() {
|
||||
referenced.extend(s.photos.iter().copied());
|
||||
}
|
||||
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
|
||||
// the fingerprints are the costly part: work them out for the candidates in parallel
|
||||
let candidates: Vec<&Photo> = self
|
||||
.photos
|
||||
.values()
|
||||
.filter(|p| p.local && !referenced.contains(&p.id))
|
||||
.filter(|p| folder_of(p).and_then(|f| self.browsed.get(&f)).is_some_and(|at| is_old(at, now_s, days, max_age)))
|
||||
.map(|p| &**p)
|
||||
.collect();
|
||||
let untouched: HashSet<PhotoId> = untouched_of(&candidates);
|
||||
let mut stamp = BTreeSet::new();
|
||||
let mut remaining: BTreeSet<String> = BTreeSet::new();
|
||||
for p in self.photos.values().filter(|p| p.local) {
|
||||
plan.local += 1;
|
||||
let Some(folder) = folder_of(p) else {
|
||||
plan.kept_in_use += 1;
|
||||
continue;
|
||||
};
|
||||
let recent = match (self.browsed.get(&folder), now_s) {
|
||||
(None, _) => {
|
||||
stamp.insert(folder.clone());
|
||||
true
|
||||
}
|
||||
(Some(at), _) => !is_old(at, now_s, days, max_age),
|
||||
};
|
||||
let keep = if recent {
|
||||
plan.kept_recent += 1;
|
||||
true
|
||||
} else if referenced.contains(&p.id) || in_use(p) {
|
||||
plan.kept_in_use += 1;
|
||||
true
|
||||
} else if !untouched.contains(&p.id) {
|
||||
plan.kept_touched += 1;
|
||||
true
|
||||
} else {
|
||||
plan.evict.push(p.id);
|
||||
false
|
||||
};
|
||||
if keep {
|
||||
remaining.insert(folder);
|
||||
}
|
||||
}
|
||||
plan.stamp = stamp.into_iter().collect();
|
||||
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
|
||||
fn is_old(at: &str, now_s: Option<i64>, days: u32, max_age: i64) -> bool {
|
||||
days > 0 && matches!((now_s, crate::stacks::iso_seconds(at)), (Some(n), Some(a)) if n - a >= max_age)
|
||||
}
|
||||
|
||||
/// The untouched ones among `photos` ([`Photo::untouched_local`]), checked on several threads
|
||||
/// where there are many.
|
||||
fn untouched_of(photos: &[&Photo]) -> HashSet<PhotoId> {
|
||||
let check = |chunk: &[&Photo]| chunk.iter().filter(|p| p.untouched_local()).map(|p| p.id).collect::<Vec<_>>();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(8);
|
||||
if threads > 1 && photos.len() >= 256 {
|
||||
let size = photos.len().div_ceil(threads);
|
||||
return std::thread::scope(|sc| {
|
||||
let jobs: Vec<_> = photos.chunks(size).map(|c| sc.spawn(move || check(c))).collect();
|
||||
// a job that panicked (it can't) leaves its photos out: they are kept
|
||||
jobs.into_iter().filter_map(|j| j.join().ok()).flatten().collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
check(photos).into_iter().collect()
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// Every photo the op refers to.
|
||||
pub fn photo_ids(&self, out: &mut impl FnMut(PhotoId)) {
|
||||
match self {
|
||||
Op::AddPhoto { photo } => out(photo.id),
|
||||
Op::RemovePhoto { id }
|
||||
| Op::SetRating { id, .. }
|
||||
| Op::SetFlag { id, .. }
|
||||
| Op::SetLabel { id, .. }
|
||||
| Op::SetDevelop { id, .. }
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetDeleted { id, .. }
|
||||
| Op::SetLocal { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetCaptured { id, .. }
|
||||
| Op::SetAnalysis { id, .. }
|
||||
| Op::SetFile { id, .. }
|
||||
| Op::Relink { id, .. }
|
||||
| Op::SetContent { id, .. } => out(*id),
|
||||
Op::AddAlbum { album } => {
|
||||
album.photos.iter().for_each(|p| out(*p));
|
||||
album.cover.into_iter().for_each(&mut *out);
|
||||
}
|
||||
Op::SetAlbumPhotos { photos, .. } | Op::SetStack { photos, .. } => photos.iter().for_each(|p| out(*p)),
|
||||
Op::SetAlbumCover { cover, .. } => cover.iter().for_each(|p| out(*p)),
|
||||
Op::AddStack { stack } => stack.photos.iter().for_each(|p| out(*p)),
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| o.photo_ids(out)),
|
||||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetBrowsed { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
|
||||
//! the library is open (issue #99).
|
||||
//!
|
||||
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
|
||||
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
|
||||
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
//!
|
||||
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
|
||||
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
|
||||
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
|
||||
//! that's actually held refuses the open.
|
||||
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
|
||||
//! the error message. It's a separate file because a Windows lock also blocks reading the
|
||||
//! locked file. It's only a hint: it may be missing or left over from a crash.
|
||||
//! - File systems without lock support (some network shares): the library opens unlocked, with a
|
||||
//! warning — refusing it would lock the user out of their own library. Locks over SMB/NFS are
|
||||
//! advisory and depend on the server; the owner file is then the only hint.
|
||||
//! - Browser storage (OPFS) has no file locks: see the web host for its own guard.
|
||||
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The lock file in the library directory.
|
||||
pub const LOCK: &str = "catalog.lock";
|
||||
/// Who holds the lock (JSON, see [`LockOwner`]).
|
||||
pub const OWNER: &str = "catalog.lock.owner";
|
||||
|
||||
/// Who has the library open (as written in [`OWNER`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LockOwner {
|
||||
pub pid: u32,
|
||||
/// Computer name, if known.
|
||||
pub host: String,
|
||||
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
|
||||
pub program: String,
|
||||
pub version: String,
|
||||
/// When it opened the library (seconds since 1970).
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
impl LockOwner {
|
||||
/// This process, as `program`.
|
||||
pub fn this_process(program: &str) -> LockOwner {
|
||||
LockOwner {
|
||||
pid: std::process::id(),
|
||||
host: host_name(),
|
||||
program: program.to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
since: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
|
||||
pub fn describe(&self) -> String {
|
||||
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
|
||||
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
|
||||
let host = if self.host.is_empty() {
|
||||
String::new()
|
||||
} else if self.host == host_name() {
|
||||
" on this computer".to_string()
|
||||
} else {
|
||||
format!(" on {}", self.host)
|
||||
};
|
||||
format!("{program}{version} (process {}{host})", self.pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// The computer's name, best effort (empty if unknown).
|
||||
fn host_name() -> String {
|
||||
let env = ["COMPUTERNAME", "HOSTNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|h| !h.trim().is_empty()));
|
||||
env.or_else(|| std::fs::read_to_string("/etc/hostname").ok()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LockError {
|
||||
/// Another process has the library open.
|
||||
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
|
||||
InUse(Option<LockOwner>),
|
||||
}
|
||||
|
||||
/// The exclusive lock on a library directory; released on drop (and by the OS if the process
|
||||
/// dies).
|
||||
#[derive(Debug)]
|
||||
pub struct LibraryLock {
|
||||
dir: PathBuf,
|
||||
/// `None` when the file system can't lock (the library is open unlocked).
|
||||
_file: Option<File>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl LibraryLock {
|
||||
/// Lock `dir` (which must exist) for this process. Fails only when another process holds it.
|
||||
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
|
||||
let unlocked = |why: io::Error| {
|
||||
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
|
||||
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
|
||||
};
|
||||
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Ok(unlocked(e)),
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
let owner = std::fs::read(dir.join(OWNER)).ok().and_then(|b| serde_json::from_slice::<LockOwner>(&b).ok());
|
||||
return Err(LockError::InUse(owner));
|
||||
}
|
||||
Err(TryLockError::Error(e)) => return Ok(unlocked(e)),
|
||||
}
|
||||
let owner = LockOwner::this_process(program);
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&owner)
|
||||
&& let Err(e) = std::fs::write(dir.join(OWNER), json)
|
||||
{
|
||||
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
|
||||
}
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
|
||||
}
|
||||
|
||||
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LibraryLock {
|
||||
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
|
||||
/// then the file handle closes and the OS releases the lock.
|
||||
fn drop(&mut self) {
|
||||
if self.held {
|
||||
let _ = std::fs::remove_file(self.dir.join(OWNER));
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
-1
@@ -93,6 +93,65 @@ pub enum Source {
|
||||
Demo { scene: u32 },
|
||||
}
|
||||
|
||||
/// Copyright status (IPTC / XMP Rights Management `xmpRights:Marked`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CopyrightStatus {
|
||||
/// Not stated (no `xmpRights:Marked`).
|
||||
#[default]
|
||||
Unknown,
|
||||
/// `xmpRights:Marked` = True.
|
||||
Copyrighted,
|
||||
/// `xmpRights:Marked` = False.
|
||||
PublicDomain,
|
||||
}
|
||||
|
||||
impl CopyrightStatus {
|
||||
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
|
||||
/// `unknown` / `copyrighted` / `publicDomain` (also `public domain`, `public-domain`).
|
||||
pub fn parse(s: &str) -> Option<CopyrightStatus> {
|
||||
match s.trim().to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
|
||||
"unknown" | "" => Some(CopyrightStatus::Unknown),
|
||||
"copyrighted" => Some(CopyrightStatus::Copyrighted),
|
||||
"publicdomain" => Some(CopyrightStatus::PublicDomain),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The id used by commands (`photo.setMeta`'s `copyrightStatus`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "unknown",
|
||||
CopyrightStatus::Copyrighted => "copyrighted",
|
||||
CopyrightStatus::PublicDomain => "publicDomain",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "Unknown",
|
||||
CopyrightStatus::Copyrighted => "Copyrighted",
|
||||
CopyrightStatus::PublicDomain => "Public Domain",
|
||||
}
|
||||
}
|
||||
/// As `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
|
||||
pub fn marked(self) -> Option<bool> {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => None,
|
||||
CopyrightStatus::Copyrighted => Some(true),
|
||||
CopyrightStatus::PublicDomain => Some(false),
|
||||
}
|
||||
}
|
||||
pub fn from_marked(m: Option<bool>) -> CopyrightStatus {
|
||||
match m {
|
||||
None => CopyrightStatus::Unknown,
|
||||
Some(true) => CopyrightStatus::Copyrighted,
|
||||
Some(false) => CopyrightStatus::PublicDomain,
|
||||
}
|
||||
}
|
||||
pub fn is_unknown(&self) -> bool {
|
||||
*self == CopyrightStatus::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptive + capture metadata.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -115,8 +174,20 @@ pub struct Meta {
|
||||
pub alt_text: String,
|
||||
pub extended_description: String,
|
||||
pub copyright: String,
|
||||
/// Copyright status, rights usage terms and copyright info URL (IPTC Core rights fields).
|
||||
#[serde(skip_serializing_if = "CopyrightStatus::is_unknown")]
|
||||
pub copyright_status: CopyrightStatus,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub usage_terms: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
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)]
|
||||
@@ -200,6 +271,16 @@ pub struct Photo {
|
||||
/// Assisted culling scores (`None` until analysed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub analysis: Option<Analysis>,
|
||||
/// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the
|
||||
/// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and
|
||||
/// edited from the camera's embedded JPEG preview — a rendered image with the camera's
|
||||
/// picture style baked in — so it is treated as a rendered (non-raw) source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
|
||||
/// whether the user changed anything since (see [`crate::local`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_baseline: Option<u64>,
|
||||
}
|
||||
|
||||
/// What assisted culling measured on a photo.
|
||||
@@ -246,8 +327,22 @@ impl Photo {
|
||||
copy_name: None,
|
||||
import_look: None,
|
||||
analysis: None,
|
||||
preview_only: None,
|
||||
local_baseline: None,
|
||||
}
|
||||
}
|
||||
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
|
||||
/// its embedded preview ([`Photo::preview_only`]).
|
||||
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 {
|
||||
@@ -259,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(),
|
||||
};
|
||||
@@ -332,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
-4
@@ -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}"));
|
||||
}
|
||||
@@ -174,6 +207,11 @@ impl Filter {
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock (only "in the last…" rules do).
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
@@ -262,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)
|
||||
{
|
||||
@@ -283,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() }
|
||||
@@ -325,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)]
|
||||
@@ -346,6 +438,52 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
let mut s = path.replace('\\', "/");
|
||||
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
|
||||
s = match rest.strip_prefix("UNC/") {
|
||||
Some(unc) => format!("//{unc}"),
|
||||
None => rest.to_string(),
|
||||
};
|
||||
}
|
||||
// the part `..` can't climb above: `//server/share`, `c:` or `/`
|
||||
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
|
||||
let mut it = unc.splitn(3, '/');
|
||||
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), rest.to_string())
|
||||
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
|
||||
(s[..2].to_ascii_lowercase(), s[2..].to_string())
|
||||
} else {
|
||||
(String::new(), s.clone())
|
||||
};
|
||||
let absolute = rest.starts_with('/') || !prefix.is_empty();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for c in rest.split('/') {
|
||||
match c {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
".." if absolute => {}
|
||||
c => parts.push(c),
|
||||
}
|
||||
}
|
||||
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
|
||||
if cfg!(windows) { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
|
||||
/// (see [`folder_key`]).
|
||||
pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
let (p, r) = (folder_key(path), folder_key(root));
|
||||
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
|
||||
/// `hdr`, `panorama` or `hdrPanorama`.
|
||||
|
||||
@@ -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),
|
||||
@@ -70,6 +71,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("location", "Location", Kind::Text),
|
||||
("creator", "Creator", Kind::Text),
|
||||
("copyright", "Copyright", Kind::Text),
|
||||
("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
|
||||
("captureDate", "Capture Date", Kind::Date),
|
||||
("importDate", "Import Date", Kind::Date),
|
||||
("editDate", "Edit Date", Kind::Date),
|
||||
@@ -303,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),
|
||||
@@ -311,6 +327,10 @@ impl Rule {
|
||||
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
|
||||
"creator" => text(&m.creator),
|
||||
"copyright" => text(&m.copyright),
|
||||
"copyrightStatus" => {
|
||||
let want = crate::CopyrightStatus::parse(&want);
|
||||
(want == Some(m.copyright_status)) == (op == "is")
|
||||
}
|
||||
"captureDate" => date_op(op, p.captured.as_deref(), value),
|
||||
"importDate" => date_op(op, Some(&p.imported), value),
|
||||
"editDate" => date_op(op, p.edited.as_deref(), value),
|
||||
@@ -341,6 +361,15 @@ impl RuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
|
||||
/// same photos can enter or leave the set without any catalog change.
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rules.iter().any(|r| match r {
|
||||
Rule::Group { group } => group.depends_on_now(),
|
||||
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
|
||||
})
|
||||
}
|
||||
|
||||
/// Unknown fields or operators (for command validation), as readable messages.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
@@ -431,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}]}));
|
||||
@@ -440,6 +480,13 @@ mod tests {
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
|
||||
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
|
||||
// copyright status (unknown until set)
|
||||
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
|
||||
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
|
||||
let mut pd = photo(2);
|
||||
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
|
||||
// nested: rating ≥ 4 and (label is blue or keywords contain food)
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! 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
|
||||
//! volume has no hard links).
|
||||
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
|
||||
//! resolved paths elsewhere), for "is this target an original?" checks.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
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
|
||||
/// this module **on the calling thread** fails once `bytes` bytes of a file were written — as a
|
||||
/// full disk or an unplugged drive would.
|
||||
#[doc(hidden)]
|
||||
pub fn fail_writes_after(bytes: u64) -> FaultGuard {
|
||||
FAIL_AFTER.with(|c| c.set(Some(bytes)));
|
||||
FaultGuard(())
|
||||
}
|
||||
|
||||
/// Ends a [`fail_writes_after`] when dropped.
|
||||
#[doc(hidden)]
|
||||
pub struct FaultGuard(());
|
||||
|
||||
impl Drop for FaultGuard {
|
||||
fn drop(&mut self) {
|
||||
FAIL_AFTER.with(|c| c.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts what is written; fails past an injected limit.
|
||||
struct Limited<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
impl<W: Write> Write for Limited<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let buf = match self.limit {
|
||||
Some(l) => {
|
||||
let room = usize::try_from(l.saturating_sub(self.n)).unwrap_or(usize::MAX);
|
||||
if room == 0 && !buf.is_empty() {
|
||||
return Err(io::Error::other("write failed (injected fault)"));
|
||||
}
|
||||
buf.get(..room.min(buf.len())).unwrap_or(buf)
|
||||
}
|
||||
None => buf,
|
||||
};
|
||||
let k = self.inner.write(buf)?;
|
||||
self.n += k as u64;
|
||||
Ok(k)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_of(path: &Path) -> &Path {
|
||||
path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."))
|
||||
}
|
||||
|
||||
/// Make a rename or a new name in `dir` durable (POSIX). Opening a directory fails on Windows,
|
||||
/// where NTFS journals the rename itself, so errors are ignored.
|
||||
pub fn sync_dir(dir: &Path) {
|
||||
if let Ok(d) = File::open(dir) {
|
||||
let _ = sync_file(&d);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty temp file next to `path` (hidden, unique in this process and folder).
|
||||
fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
if path.file_name().is_none() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{} is not a file path", path.display())));
|
||||
}
|
||||
let dir = parent_of(path);
|
||||
for _ in 0..1000 {
|
||||
let tmp = dir.join(format!(".lightcraft-{}-{}.tmp", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => return Ok((tmp, f)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
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 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);
|
||||
let mut w = Limited { inner: io::BufWriter::with_capacity(1 << 20, file), n: 0, limit };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let n = w.n;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
if sync {
|
||||
sync_file(&f)?;
|
||||
}
|
||||
Ok(n)
|
||||
})();
|
||||
match r {
|
||||
Ok(n) => Ok((tmp, n)),
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace (or create) `path` with `data` atomically and durably; see the module docs. The
|
||||
/// folder must exist.
|
||||
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, &mut |w| w.write_all(data)).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`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> {
|
||||
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);
|
||||
}
|
||||
if sync {
|
||||
sync_dir(parent_of(path));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write `data` as the new file `path`: never replaces an existing file (`AlreadyExists`), and
|
||||
/// the file only appears once it is complete, synced and read back identical.
|
||||
pub fn write_new(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
write_new_unique(&mut std::iter::once(path.to_path_buf()), data).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`write_new`] under the first free name of `candidates` (all in the same folder, e.g.
|
||||
/// `IMG_1.dng`, `IMG_1-2.dng`, …): the file is written once and then given the first name that
|
||||
/// is not taken. Returns that name; `AlreadyExists` when every candidate is taken.
|
||||
pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u8]) -> io::Result<PathBuf> {
|
||||
let mut candidates = candidates.peekable();
|
||||
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, 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?)"));
|
||||
}
|
||||
for path in candidates.by_ref().take(100_000) {
|
||||
match publish_new(&tmp, &path) {
|
||||
Ok(()) => {
|
||||
sync_dir(parent_of(&path));
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: no free file name", first.display())))
|
||||
})();
|
||||
// after a hard link the temp name is a second name of the new file; after a rename it's gone
|
||||
let _ = fs::remove_file(&tmp);
|
||||
r
|
||||
}
|
||||
|
||||
/// Give the complete temp file `tmp` the name `path`, unless that is taken.
|
||||
fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
|
||||
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
|
||||
Err(_) => {
|
||||
if fs::symlink_metadata(path).is_ok() {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
|
||||
}
|
||||
fs::rename(tmp, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the file at `path` hold exactly `data`?
|
||||
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
|
||||
use std::io::Read;
|
||||
let mut f = File::open(path)?;
|
||||
if f.metadata()?.len() != data.len() as u64 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut at = 0usize;
|
||||
loop {
|
||||
let k = match f.read(&mut buf) {
|
||||
Ok(0) => return Ok(at == data.len()),
|
||||
Ok(k) => k,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if data.get(at..at + k) != buf.get(..k) {
|
||||
return Ok(false);
|
||||
}
|
||||
at += k;
|
||||
}
|
||||
}
|
||||
|
||||
/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)?
|
||||
pub fn same_file(a: &Path, b: &Path) -> bool {
|
||||
let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false };
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
ma.dev() == mb.dev() && ma.ino() == mb.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if ma.len() != mb.len() {
|
||||
return false;
|
||||
}
|
||||
match (fs::canonicalize(a), fs::canonicalize(b)) {
|
||||
(Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-safe-file-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_and_leaves_no_temp() {
|
||||
let d = temp("replace");
|
||||
let p = d.join("out.jpg");
|
||||
write_atomic(&p, b"one").unwrap();
|
||||
write_atomic(&p, b"two two").unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two two");
|
||||
assert_eq!(names(&d), vec!["out.jpg"]);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_mid_write_keeps_the_old_file() {
|
||||
let d = temp("midwrite");
|
||||
let p = d.join("photo.jpg");
|
||||
fs::write(&p, b"the original bytes").unwrap();
|
||||
{
|
||||
let _f = fail_writes_after(4);
|
||||
let e = write_atomic(&p, &[7u8; 10_000]).unwrap_err();
|
||||
assert!(e.to_string().contains("injected"), "{e}");
|
||||
assert!(write_new(&d.join("new.dng"), &[1u8; 100]).is_err());
|
||||
}
|
||||
assert_eq!(fs::read(&p).unwrap(), b"the original bytes", "the target is untouched");
|
||||
assert_eq!(names(&d), vec!["photo.jpg"], "no temp file and no partial new file");
|
||||
// a fill error aborts the same way
|
||||
let r = write_atomic_with(&p, &mut |w| {
|
||||
w.write_all(b"half")?;
|
||||
Err(io::Error::other("encoder failed"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(fs::read(&p).unwrap(), b"the original bytes");
|
||||
assert_eq!(names(&d), vec!["photo.jpg"]);
|
||||
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");
|
||||
let p = d.join("a.dng");
|
||||
fs::write(&p, b"keep").unwrap();
|
||||
assert_eq!(write_new(&p, b"other").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&p).unwrap(), b"keep");
|
||||
let mut c = (1..).map(|k| if k == 1 { d.join("a.dng") } else { d.join(format!("a-{k}.dng")) });
|
||||
let got = write_new_unique(&mut c, b"fresh").unwrap();
|
||||
assert_eq!(got, d.join("a-2.dng"));
|
||||
assert_eq!(fs::read(&got).unwrap(), b"fresh");
|
||||
assert_eq!(names(&d), vec!["a-2.dng", "a.dng"], "the temp file is gone");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_file_sees_through_other_spellings() {
|
||||
let d = temp("same");
|
||||
let p = d.join("x.jpg");
|
||||
fs::write(&p, b"x").unwrap();
|
||||
fs::write(d.join("y.jpg"), b"x").unwrap();
|
||||
fs::create_dir_all(d.join("sub")).unwrap();
|
||||
assert!(same_file(&p, &d.join("sub/../x.jpg")));
|
||||
assert!(!same_file(&p, &d.join("y.jpg")));
|
||||
assert!(!same_file(&p, &d.join("missing.jpg")));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ mod tests {
|
||||
#[test]
|
||||
fn group_merge_set_top_remove_and_arrange() {
|
||||
let (mut c, ids) = cat(6, &[]);
|
||||
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
|
||||
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
|
||||
let op = c.group_ops(b, &[a, b, x], false).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
let s = c.stack_of(a).unwrap().clone();
|
||||
|
||||
@@ -16,12 +16,33 @@ pub trait Store: Send {
|
||||
/// Replace a file atomically: after a crash either the old or the new content is visible,
|
||||
/// never a mix (native: write temp + fsync + rename + fsync directory).
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Append and make durable (fsync) before returning.
|
||||
/// [`Store::write_atomic`] with streamed content: `fill` writes the new content into the
|
||||
/// given writer (an error aborts and leaves the old file). Same durability: the file is
|
||||
/// replaced only once all of it is written (native: and fsynced). Returns its length.
|
||||
///
|
||||
/// The default buffers the content in memory; [`FsStore`] streams it into the temp file, so
|
||||
/// a large snapshot is never held in memory as a whole.
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
self.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
/// Append and make durable (fsync) before returning. On an error, part of `data` may have
|
||||
/// been written (a full disk, a dropped network share): the journal cuts the file back to its
|
||||
/// previous length with [`Store::truncate`] before appending again.
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again).
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again). A missing file is
|
||||
/// left missing.
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
|
||||
/// Human-readable location (diagnostics).
|
||||
fn describe(&self) -> String;
|
||||
/// Another handle on the same files that a worker thread can write `catalog.snap` through
|
||||
/// while this one keeps appending to the log (background compaction). `None` (the default):
|
||||
/// snapshots are written synchronously.
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in a directory.
|
||||
@@ -77,6 +98,32 @@ impl Store for FsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
let tmp = self.path(&format!("{name}.tmp"));
|
||||
let written = (|| {
|
||||
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
f.sync_all()?;
|
||||
Ok(w.n)
|
||||
})();
|
||||
let n = match written {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
std::fs::rename(&tmp, self.path(name))?;
|
||||
self.sync_dir();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
|
||||
@@ -87,16 +134,27 @@ impl Store for FsStore {
|
||||
}
|
||||
self.appender = Some((name.to_string(), f));
|
||||
}
|
||||
let (_, f) = self.appender.as_mut().expect("appender just opened");
|
||||
f.write_all(data)?;
|
||||
f.sync_data()
|
||||
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
|
||||
let written = f.write_all(data).and_then(|()| f.sync_data());
|
||||
if written.is_err() {
|
||||
// The handle may be dead (a network share that reconnected, a removed volume): the next
|
||||
// attempt reopens the file. Part of `data` may be in the file; the caller cuts it back
|
||||
// ([`Store::truncate`]) before appending again.
|
||||
self.appender = None;
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
|
||||
let f = match std::fs::OpenOptions::new().write(true).open(self.path(name)) {
|
||||
Ok(f) => f,
|
||||
// nothing to cut (an append that failed before creating the file)
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
f.set_len(len)?;
|
||||
f.sync_all()
|
||||
}
|
||||
@@ -104,12 +162,36 @@ impl Store for FsStore {
|
||||
fn describe(&self) -> String {
|
||||
self.dir.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(FsStore { dir: self.dir.clone(), appender: None }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the bytes written through it.
|
||||
struct Counter<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
impl<W: io::Write> io::Write for Counter<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let k = self.inner.write(buf)?;
|
||||
self.n += k as u64;
|
||||
Ok(k)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemStore {
|
||||
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
|
||||
/// Offer a [`Store::background_writer`] (off by default: snapshots stay synchronous).
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
impl MemStore {
|
||||
@@ -145,4 +227,7 @@ impl Store for MemStore {
|
||||
fn describe(&self) -> String {
|
||||
"memory".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
|
||||
}
|
||||
}
|
||||
@@ -231,3 +231,218 @@ fn merge_results_are_recognised() {
|
||||
assert_eq!(merged_kind("hdr.jpg"), None);
|
||||
assert_eq!(merged_kind("panorama.jpg"), None);
|
||||
}
|
||||
|
||||
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
|
||||
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
|
||||
/// with an exact inverse that survives serialisation (the journal).
|
||||
#[test]
|
||||
fn preview_only_survives_serde_and_set_content_undo() {
|
||||
let mut c = Catalog::new();
|
||||
let a = c.alloc_photo_id();
|
||||
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
|
||||
assert!(!p.develops_raw());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
// the catalog round-trips it
|
||||
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
||||
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
||||
// an older catalog (no field) loads as decodable
|
||||
let mut v = serde_json::to_value(&c).unwrap();
|
||||
let photos = v["photos"].as_object_mut().unwrap();
|
||||
let ph = photos.values_mut().next().unwrap();
|
||||
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
|
||||
let old: Catalog = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.photo(a).unwrap().preview_only, None);
|
||||
assert!(old.photo(a).unwrap().develops_raw());
|
||||
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
|
||||
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert!(c.photo(a).unwrap().develops_raw());
|
||||
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
||||
assert_eq!(c.photo(a).unwrap().width, 6000);
|
||||
}
|
||||
|
||||
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
|
||||
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
|
||||
#[test]
|
||||
fn folder_identity_ignores_spelling() {
|
||||
use crate::query::{folder_key, folder_within};
|
||||
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
|
||||
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
|
||||
for (a, b) in [
|
||||
("D:/Example/Photos", "D:\\Example\\Photos"),
|
||||
("D:/Example/Photos", "d:\\Example\\Photos\\"),
|
||||
("D:\\Example/Photos", "D:/Example//Photos/."),
|
||||
("D:/Example/Photos", "D:/Example/Other/../Photos"),
|
||||
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
|
||||
("\\\\server\\share\\Photos", "//server/share/Photos/"),
|
||||
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
|
||||
("/home/me/Photos", "/home/me/Photos/"),
|
||||
("/home/me/Photos", "/home//me/./Photos"),
|
||||
("/", "//"),
|
||||
("C:\\", "c:/"),
|
||||
] {
|
||||
same(a, b);
|
||||
}
|
||||
differ("D:/Example/Photos", "D:/Example/Photos2");
|
||||
differ("D:/Example/Photos", "E:/Example/Photos");
|
||||
differ("/home/me/Photos", "/home/me");
|
||||
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
|
||||
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
|
||||
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
|
||||
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Background compaction (issue #37): a snapshot written by a worker while appends go on.
|
||||
//! "Crashes" are simulated by copying the files at a given moment and loading the copy: every
|
||||
//! acknowledged (appended) op must be there, in order, whatever the snapshot was doing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT};
|
||||
use crate::*;
|
||||
|
||||
/// A MemStore whose snapshot writes wait for a gate (to hold a background snapshot mid-flight)
|
||||
/// and can be made to fail.
|
||||
#[derive(Clone)]
|
||||
struct Gated {
|
||||
files: MemStore,
|
||||
gate: Arc<(Mutex<bool>, Condvar)>,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Gated {
|
||||
fn new() -> Gated {
|
||||
Gated { files: MemStore::new(), gate: Arc::new((Mutex::new(false), Condvar::new())), fail: Arc::new(AtomicBool::new(false)) }
|
||||
}
|
||||
fn set_gate(&self, open: bool) {
|
||||
*self.gate.0.lock().unwrap() = open;
|
||||
self.gate.1.notify_all();
|
||||
}
|
||||
/// The files as a crash at this moment would leave them.
|
||||
fn crash(&self) -> MemStore {
|
||||
let m = MemStore::new();
|
||||
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
|
||||
m
|
||||
}
|
||||
fn snapshot_seq_on_disk(&self) -> Option<u64> {
|
||||
let v: serde_json::Value = serde_json::from_slice(&self.files.get(SNAPSHOT)?).ok()?;
|
||||
v["seq"].as_u64()
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Gated {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> std::io::Result<u64> {
|
||||
if name == SNAPSHOT {
|
||||
let (lock, cv) = &*self.gate;
|
||||
let (open, timeout) = cv.wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(20), |open| !*open).unwrap();
|
||||
if !*open || timeout.timed_out() {
|
||||
return Err(std::io::Error::other("gate never opened"));
|
||||
}
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"gated".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(self.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply and durably append one edit (two ops in one append every fourth time).
|
||||
fn edit(j: &mut Journal, live: &mut Catalog, k: u64) {
|
||||
let one = |live: &mut Catalog, k: u64| {
|
||||
let op = if live.len() < 4 || k.is_multiple_of(3) {
|
||||
let id = live.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "t")) }
|
||||
} else {
|
||||
Op::SetRating { id: PhotoId(1 + k % live.len() as u64), rating: (k % 6) as u8 }
|
||||
};
|
||||
live.apply(op.clone()).unwrap();
|
||||
op
|
||||
};
|
||||
let mut ops = vec![one(live, k)];
|
||||
if k.is_multiple_of(4) {
|
||||
ops.push(one(live, k / 4 + 1));
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
}
|
||||
|
||||
/// Load the files a crash now would leave: they must hold exactly the live state.
|
||||
fn assert_recovers(g: &Gated, live: &Catalog) -> LoadReport {
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot(), "{r:?}");
|
||||
assert_eq!((r.failed, r.torn_bytes, r.damaged.clone()), (0, 0, None));
|
||||
r
|
||||
}
|
||||
|
||||
fn wait_written(j: &Journal) {
|
||||
for _ in 0..20_000 {
|
||||
if j.snapshot_written() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
panic!("background snapshot never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_snapshot_keeps_edits_made_while_it_runs() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..30 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
assert!(j.snapshot_running() && !j.wants_snapshot() && j.stats().snapshot_running);
|
||||
|
||||
// edits while the snapshot is being written are appended (durably) as usual
|
||||
let mut during = 0;
|
||||
for k in 30..50 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
// crash before the snapshot landed: no snapshot yet, the whole log replays
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed as u64), (0, j.seq()));
|
||||
|
||||
// the snapshot lands (op n); crash before the log was trimmed: its records are stale
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
for k in 50..55 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, during));
|
||||
|
||||
// finishing trims the log to exactly the records after the snapshot, in order
|
||||
assert!(j.poll().unwrap());
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (n, during));
|
||||
let log = String::from_utf8(g.files.get(LOG).unwrap()).unwrap();
|
||||
let seqs: Vec<u64> = log.lines().map(|l| journal::decode_record(l).unwrap().0).collect();
|
||||
assert_eq!(seqs, ((n + 1)..=j.seq()).collect::<Vec<_>>());
|
||||
assert_eq!(j.log_bytes(), log.len() as u64);
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale, r.replayed as u64), (n, 0, during));
|
||||
let s = j.stats();
|
||||
assert!(s.last_snapshot.background && s.last_snapshot.records == n && !s.snapshot_running, "{s:?}");
|
||||
|
||||
// and appending goes on after it
|
||||
edit(&mut j, &mut live, 99);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
j.policy = SnapshotPolicy { max_records: 10, max_bytes: u64::MAX };
|
||||
for k in 0..12 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
assert!(j.wants_snapshot());
|
||||
g.fail.store(true, Ordering::SeqCst);
|
||||
g.set_gate(true);
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
edit(&mut j, &mut live, 12);
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
// still the (empty) snapshot written when the library was created
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(0));
|
||||
assert_recovers(&g, &live);
|
||||
// no retry storm: the next attempt waits for another `max_records` records
|
||||
assert!(!j.wants_snapshot());
|
||||
g.fail.store(false, Ordering::SeqCst);
|
||||
while !j.wants_snapshot() {
|
||||
let k = j.seq();
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
j.wait().unwrap();
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (j.seq(), 0));
|
||||
}
|
||||
|
||||
/// A synchronous snapshot (close, "Optimize Library") waits for the background one, so the older
|
||||
/// snapshot can't land after (and replace) the newer one.
|
||||
#[test]
|
||||
fn sync_snapshot_waits_for_the_background_one() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..20 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
j.snapshot(&live).unwrap();
|
||||
opener.join().unwrap();
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
assert!(g.files.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.stats().snapshots, 2);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
/// Dropping the journal (e.g. reopening the library) waits for the snapshot in flight and leaves
|
||||
/// the log whole: reopening finds every op.
|
||||
#[test]
|
||||
fn dropping_the_journal_waits_for_its_snapshot() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..15 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
let total = j.seq();
|
||||
drop(j);
|
||||
opener.join().unwrap();
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
let (_, c, r) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, total - n));
|
||||
}
|
||||
|
||||
/// On disk, with a real worker thread: a crash mid-write leaves only a temp file, which loading
|
||||
/// ignores; edits during the snapshot survive; reopening resumes appends.
|
||||
#[test]
|
||||
fn fs_store_background_snapshot() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-bg-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
for k in 0..200 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
// a previous crash mid-snapshot left a partial temp file
|
||||
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"lightcraft-catal").unwrap();
|
||||
let (mut j2, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((c2.to_snapshot(), r.snapshot_seq), (live.to_snapshot(), 0));
|
||||
drop(j);
|
||||
let n = j2.seq();
|
||||
j2.snapshot_in_background(&live).unwrap();
|
||||
for k in 200..260 {
|
||||
edit(&mut j2, &mut live, k);
|
||||
}
|
||||
j2.wait().unwrap();
|
||||
assert_eq!((j2.snapshot_seq(), j2.log_records()), (n, j2.seq() - n));
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
edit(&mut j2, &mut live, 999);
|
||||
drop(j2);
|
||||
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!(c3.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale), (n, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Any interleaving of edits, background snapshots (started, landed, finished), synchronous
|
||||
/// snapshots and crashes: a crash at any point recovers exactly the acknowledged ops.
|
||||
#[test]
|
||||
fn background_compaction_never_loses_acknowledged_ops(actions in proptest::collection::vec((0u8..8, 0u64..1000), 1..60)) {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for (ctl, k) in actions {
|
||||
match ctl {
|
||||
0..=2 => edit(&mut j, &mut live, k),
|
||||
3 => j.snapshot_in_background(&live).unwrap(),
|
||||
4 | 5 => {
|
||||
// the worker writes its snapshot; finish it now or later
|
||||
if j.snapshot_running() {
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
g.set_gate(false);
|
||||
if ctl == 4 {
|
||||
prop_assert!(j.poll().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
g.set_gate(true);
|
||||
j.snapshot(&live).unwrap();
|
||||
g.set_gate(false);
|
||||
prop_assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
}
|
||||
_ => {
|
||||
// a crash now
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
prop_assert_eq!(r.failed, 0);
|
||||
prop_assert!(r.damaged.is_none());
|
||||
}
|
||||
}
|
||||
// the snapshot on disk never covers more than what was appended
|
||||
prop_assert!(g.snapshot_seq_on_disk().unwrap_or(0) <= j.seq());
|
||||
}
|
||||
g.set_gate(true);
|
||||
let (_, c, _) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
drop(j);
|
||||
let (_, c, _) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is
|
||||
//! refused with a clear error and left untouched (never read in part, truncated or set aside).
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record};
|
||||
use crate::*;
|
||||
|
||||
fn photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// A catalog with two photos and the log line for a third op on top of it.
|
||||
fn legacy_parts() -> (Catalog, String, Catalog) {
|
||||
let mut c = Catalog::new();
|
||||
for _ in 0..2 {
|
||||
let op = photo(&mut c);
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let base = c.clone();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let op = Op::SetRating { id, rating: 4 };
|
||||
c.apply(op.clone()).unwrap();
|
||||
(base, format!("{}\n", encode_record(3, &op)), c)
|
||||
}
|
||||
|
||||
fn snapshot_version(m: &MemStore) -> u64 {
|
||||
let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
|
||||
v["version"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(1)));
|
||||
// rewritten in this format before anything is appended
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
assert!(m.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.seq(), 3);
|
||||
j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap();
|
||||
drop(j);
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
let op = photo(&mut c);
|
||||
c.apply(op.clone()).unwrap();
|
||||
let m = MemStore::new();
|
||||
m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes());
|
||||
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
assert_eq!(r.upgraded_from, Some(0));
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
/// A new library gets a snapshot in this format at once, so an older build never sees a bare log
|
||||
/// it would read in part.
|
||||
#[test]
|
||||
fn new_library_is_written_in_this_format() {
|
||||
let m = MemStore::new();
|
||||
let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert!(r.created);
|
||||
assert_eq!(r.upgraded_from, None);
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
fn assert_refused_untouched(m: &MemStore) {
|
||||
let before = m.files.lock().unwrap().clone();
|
||||
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
|
||||
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
|
||||
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_snapshot_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
// even when its catalog doesn't parse as this version's
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(),
|
||||
);
|
||||
assert_refused_untouched(&m);
|
||||
let e = Journal::open(Box::new(m)).err().unwrap().to_string();
|
||||
assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}");
|
||||
}
|
||||
|
||||
/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a
|
||||
/// torn tail (truncated) or damage (renamed) — wherever it is in the log.
|
||||
#[test]
|
||||
fn unknown_op_in_the_log_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let future = |seq: u64| {
|
||||
let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#;
|
||||
format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()))
|
||||
};
|
||||
let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot());
|
||||
let id = base.photos().next().unwrap().id;
|
||||
let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 }));
|
||||
for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] {
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.clone().into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
// a known op with a field of an unknown shape is "newer" too
|
||||
let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#;
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.into_bytes());
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
|
||||
/// A CRC mismatch is still damage / a torn tail (not "newer").
|
||||
#[test]
|
||||
fn bad_crc_is_still_a_torn_tail() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
let body = r#"{"op":"fromTheFuture"}"#;
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes());
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}");
|
||||
}
|
||||
|
||||
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
|
||||
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
|
||||
/// variant here under the new version.
|
||||
#[test]
|
||||
fn op_variants_are_versioned() {
|
||||
fn since(op: &Op) -> u32 {
|
||||
match op {
|
||||
Op::AddPhoto { .. }
|
||||
| Op::RemovePhoto { .. }
|
||||
| Op::SetRating { .. }
|
||||
| Op::SetFlag { .. }
|
||||
| Op::SetLabel { .. }
|
||||
| Op::SetDevelop { .. }
|
||||
| Op::SetMeta { .. }
|
||||
| Op::SetDeleted { .. }
|
||||
| Op::SetLocal { .. }
|
||||
| Op::SetVersions { .. }
|
||||
| Op::SetHistory { .. }
|
||||
| Op::PushHistory { .. }
|
||||
| Op::AddAlbum { .. }
|
||||
| Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumPhotos { .. }
|
||||
| Op::SetAlbumCover { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::AddStack { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetStack { .. }
|
||||
| Op::SetCaptured { .. }
|
||||
| Op::SetAnalysis { .. }
|
||||
| Op::SetFile { .. }
|
||||
| Op::Relink { .. }
|
||||
| Op::SetContent { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -274,3 +274,53 @@ fn fs_store_roundtrip() {
|
||||
assert_eq!(c3.to_snapshot(), c.to_snapshot());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The streamed snapshot is byte-for-byte the format written before streaming (issue #37), so
|
||||
/// existing libraries load and older builds can read new snapshots; FsStore and MemStore agree.
|
||||
#[test]
|
||||
fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
let (m, _, full) = sample_catalog_with_log();
|
||||
let (mut j, c, _) = open(&m);
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
j.snapshot(&c).unwrap();
|
||||
let legacy = format!(
|
||||
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":{},\"catalog\":{}}}\n",
|
||||
crate::journal::VERSION,
|
||||
j.seq(),
|
||||
c.to_snapshot()
|
||||
);
|
||||
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
|
||||
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-stream-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// an old-style snapshot (written by a build before streaming) loads, and is rewritten the same
|
||||
std::fs::write(dir.join(SNAPSHOT), legacy.as_bytes()).unwrap();
|
||||
let (mut fj, fc, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((fc.to_snapshot(), r.snapshot_seq), (c.to_snapshot(), j.seq()));
|
||||
fj.snapshot(&fc).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), legacy.as_bytes());
|
||||
drop(fj);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A snapshot that fails while streaming (serialisation or I/O error) leaves the old file in
|
||||
/// place and removes its temp file.
|
||||
#[test]
|
||||
fn failed_streamed_write_keeps_the_old_file() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-streamfail-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.write_atomic(SNAPSHOT, b"old").unwrap();
|
||||
let r = s.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
w.write_all(b"partial new content")?;
|
||||
Err(std::io::Error::other("serialisation failed"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"old");
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
assert_eq!(s.write_atomic_with(SNAPSHOT, &mut |w| w.write_all(b"new")).unwrap(), 3);
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Forgetting untouched Local browse records (see [`crate::local`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const OLD: &str = "2026-08-01T10:00:00";
|
||||
const NOW: &str = "2026-10-05T10:00:00";
|
||||
|
||||
/// A Local record of `/pics/<folder>/IMG_<i>.JPG` as a browse catalogues it (with its baseline).
|
||||
fn local(c: &mut Catalog, folder: &str, i: u32) -> PhotoId {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder}/IMG_{i}.JPG") }, &format!("IMG_{i}.JPG"), "JPEG", 60, 40, OLD);
|
||||
p.meta.camera = "Cam".into();
|
||||
p.meta.creator = "From EXIF".into(); // file metadata is part of the baseline, not a user change
|
||||
p.focal_and_wb_for_test();
|
||||
p.local = true;
|
||||
p.set_local_baseline();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
fn focal_and_wb_for_test(&mut self) {
|
||||
self.meta.focal_mm = Some(35.3);
|
||||
self.as_shot_wb = Some((5123.4, 3.1));
|
||||
}
|
||||
}
|
||||
|
||||
fn browsed(c: &mut Catalog, folder: &str, at: &str) {
|
||||
c.apply(Op::SetBrowsed { folder: format!("/pics/{folder}"), at: Some(at.into()) }).unwrap();
|
||||
}
|
||||
|
||||
fn plan(c: &Catalog, days: u32) -> ForgetPlan {
|
||||
c.forget_local_plan(NOW, days, &|_| false)
|
||||
}
|
||||
|
||||
fn run(c: &mut Catalog, days: u32) -> ForgetPlan {
|
||||
let p = plan(c, days);
|
||||
for op in p.ops() {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untouched_records_of_old_folders_are_forgotten() {
|
||||
let mut c = Catalog::new();
|
||||
let old: Vec<_> = (0..5).map(|i| local(&mut c, "old", i)).collect();
|
||||
let recent: Vec<_> = (0..3).map(|i| local(&mut c, "recent", i)).collect();
|
||||
browsed(&mut c, "old", OLD);
|
||||
browsed(&mut c, "recent", "2026-10-01T09:00:00");
|
||||
// a library photo in the old folder is never a candidate
|
||||
let lib = c.alloc_photo_id();
|
||||
let mut p = Photo::new(lib, Source::File { path: "/pics/old/LIB.JPG".into() }, "LIB.JPG", "JPEG", 6, 4, OLD);
|
||||
p.set_local_baseline();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
|
||||
let dry = plan(&c, 30);
|
||||
assert_eq!((dry.local, dry.evict.len(), dry.kept_recent), (8, 5, 3), "{dry:?}");
|
||||
assert_eq!(dry.evict, old);
|
||||
assert_eq!(c.len(), 9, "a plan changes nothing");
|
||||
let done = run(&mut c, 30);
|
||||
assert_eq!(done.evict.len(), 5);
|
||||
assert!(old.iter().all(|id| c.photo(*id).is_none()));
|
||||
assert!(recent.iter().all(|id| c.photo(*id).is_some()));
|
||||
assert!(c.photo(lib).is_some());
|
||||
// the folder has no Local records left: its time goes
|
||||
assert_eq!(done.unstamp, vec!["/pics/old".to_string()]);
|
||||
assert_eq!(c.last_browsed("/pics/old"), None);
|
||||
assert_eq!(c.last_browsed("/pics/recent/"), Some("2026-10-01T09:00:00"));
|
||||
// 0 days = never
|
||||
browsed(&mut c, "recent", OLD);
|
||||
assert!(plan(&c, 0).evict.is_empty());
|
||||
assert_eq!(plan(&c, 30).evict.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_user_change_keeps_a_record() {
|
||||
type Change = Box<dyn Fn(&mut Catalog, PhotoId)>;
|
||||
let set = |f: fn(&mut Photo)| -> Change {
|
||||
Box::new(move |c: &mut Catalog, id: PhotoId| {
|
||||
let mut p = (**c.photo(id).unwrap()).clone();
|
||||
f(&mut p);
|
||||
c.apply(Op::RemovePhoto { id }).unwrap();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
})
|
||||
};
|
||||
let op = |f: fn(PhotoId) -> Op| -> Change { Box::new(move |c: &mut Catalog, id: PhotoId| drop(c.apply(f(id)).unwrap())) };
|
||||
let edited = || {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = 0.3;
|
||||
Arc::new(d)
|
||||
};
|
||||
let changes: Vec<(&str, Change)> = vec![
|
||||
("rating", op(|id| Op::SetRating { id, rating: 3 })),
|
||||
("flag", op(|id| Op::SetFlag { id, flag: Flag::Reject })),
|
||||
("label", op(|id| Op::SetLabel { id, label: Some(ColorLabel::Red) })),
|
||||
("develop", Box::new(move |c, id| drop(c.apply(Op::SetDevelop { id, settings: edited(), label: "Exposure".into(), edited: None }).unwrap()))),
|
||||
("crop", set(|p| Arc::make_mut(&mut p.develop).crop.flip_h = true)),
|
||||
(
|
||||
"history only",
|
||||
Box::new(|c, id| {
|
||||
let step = HistoryStep { label: "Reset".into(), settings: Arc::new(DevelopSettings::default()) };
|
||||
drop(c.apply(Op::PushHistory { id, step }).unwrap())
|
||||
}),
|
||||
),
|
||||
("versions", set(|p| p.versions.push(Version { name: "v".into(), created: NOW.into(), settings: p.develop.clone(), auto: false }))),
|
||||
("title", set(|p| p.meta.title = "T".into())),
|
||||
("caption", set(|p| p.meta.caption = "C".into())),
|
||||
("keywords", set(|p| p.meta.keywords.push("k".into()))),
|
||||
("location", set(|p| p.meta.city = "Oslo".into())),
|
||||
("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))),
|
||||
("copyright", set(|p| p.meta.copyright = "© me".into())),
|
||||
("creator edit", set(|p| p.meta.creator = "Me".into())),
|
||||
("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),
|
||||
("rename", set(|p| p.file_name = "renamed.JPG".into())),
|
||||
("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })),
|
||||
("deleted", op(|id| Op::SetDeleted { id, deleted: true })),
|
||||
("pick", op(|id| Op::SetFlag { id, flag: Flag::Pick })),
|
||||
(
|
||||
"album",
|
||||
Box::new(|c, id| {
|
||||
let a = c.alloc_album_id();
|
||||
let mut al = Album::new(a, "A");
|
||||
al.photos.push(id);
|
||||
drop(c.apply(Op::AddAlbum { album: al }).unwrap())
|
||||
}),
|
||||
),
|
||||
(
|
||||
"stack",
|
||||
Box::new(|c, id| {
|
||||
let other = local(c, "elsewhere", 99);
|
||||
let s = c.alloc_stack_id();
|
||||
drop(c.apply(Op::AddStack { stack: Stack { id: s, photos: vec![id, other], collapsed: false } }).unwrap())
|
||||
}),
|
||||
),
|
||||
(
|
||||
"virtual copy",
|
||||
Box::new(|c, id| {
|
||||
let mut v = (**c.photo(id).unwrap()).clone();
|
||||
v.id = c.alloc_photo_id();
|
||||
v.copy_of = Some(id);
|
||||
v.copy_name = Some("Copy 1".into());
|
||||
v.local = false;
|
||||
drop(c.apply(Op::AddPhoto { photo: Box::new(v) }).unwrap())
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (what, change) in changes {
|
||||
let mut c = Catalog::new();
|
||||
let id = local(&mut c, "old", 1);
|
||||
let control = local(&mut c, "old", 2);
|
||||
browsed(&mut c, "old", OLD);
|
||||
change(&mut c, id);
|
||||
let p = plan(&c, 30);
|
||||
assert_eq!(p.evict, vec![control], "{what}: {p:?}");
|
||||
assert_eq!(p.kept_touched + p.kept_in_use, 1, "{what}: {p:?}");
|
||||
// the stamp stays: a record in the folder remains
|
||||
assert!(p.unstamp.is_empty(), "{what}");
|
||||
}
|
||||
// a change undone (back to the browse state) is untouched again
|
||||
let mut c = Catalog::new();
|
||||
let id = local(&mut c, "old", 1);
|
||||
browsed(&mut c, "old", OLD);
|
||||
let inv = c.apply(Op::SetRating { id, rating: 2 }).unwrap();
|
||||
assert!(plan(&c, 30).evict.is_empty());
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(plan(&c, 30).evict, vec![id]);
|
||||
}
|
||||
|
||||
/// Catalogs from before last-browsed times (and baselines): nothing is forgotten on the first
|
||||
/// run — the folders are stamped now — and baseline-less records are judged by their data.
|
||||
#[test]
|
||||
fn upgrade_without_times_forgets_nothing_then_counts_from_then() {
|
||||
let mut c = Catalog::new();
|
||||
let a = local(&mut c, "a", 1);
|
||||
// legacy records: no baseline
|
||||
let legacy = |c: &mut Catalog, i: u32, f: fn(&mut Photo)| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/a/L{i}.JPG") }, "L.JPG", "JPEG", 6, 4, OLD);
|
||||
p.meta.camera = "Cam".into();
|
||||
p.local = true;
|
||||
f(&mut p);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let clean = legacy(&mut c, 1, |_| {});
|
||||
let rated = legacy(&mut c, 2, |p| p.rating = 1);
|
||||
let with_creator = legacy(&mut c, 3, |p| p.meta.creator = "maybe the user".into());
|
||||
let p = run(&mut c, 30);
|
||||
assert!(p.evict.is_empty(), "{p:?}");
|
||||
assert_eq!(p.stamp, vec!["/pics/a".to_string()]);
|
||||
assert_eq!(c.last_browsed("/pics/a"), Some(NOW));
|
||||
// 30+ days later
|
||||
let later = c.forget_local_plan("2026-11-06T10:00:00", 30, &|_| false);
|
||||
let mut evict = later.evict.clone();
|
||||
evict.sort();
|
||||
assert_eq!(evict, vec![a, clean]);
|
||||
assert_eq!(later.kept_touched, 2, "{rated:?} {with_creator:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_use_records_are_kept() {
|
||||
let mut c = Catalog::new();
|
||||
let a = local(&mut c, "old", 1);
|
||||
let b = local(&mut c, "old", 2);
|
||||
browsed(&mut c, "old", OLD);
|
||||
let p = c.forget_local_plan(NOW, 30, &|p| p.id == a);
|
||||
assert_eq!((p.evict.clone(), p.kept_in_use), (vec![b], 1));
|
||||
}
|
||||
|
||||
/// The ops are journaled: a crash right after forgetting reloads the same catalog, and a
|
||||
/// fingerprint survives the snapshot round trip (a reloaded record is still untouched).
|
||||
#[test]
|
||||
fn forgetting_is_journaled_and_replays() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..10 {
|
||||
local(&mut c, if i % 2 == 0 { "old" } else { "new" }, i);
|
||||
}
|
||||
// log what was built so far: the ops are the records as added
|
||||
for p in c.photos() {
|
||||
ops.push(Op::AddPhoto { photo: Box::new((**p).clone()) });
|
||||
}
|
||||
ops.push(Op::SetBrowsed { folder: "/pics/old".into(), at: Some(OLD.into()) });
|
||||
ops.push(Op::SetBrowsed { folder: "/pics/new".into(), at: Some(NOW.into()) });
|
||||
browsed(&mut c, "old", OLD);
|
||||
browsed(&mut c, "new", NOW);
|
||||
j.append(&ops).unwrap();
|
||||
j.snapshot(&c).unwrap();
|
||||
drop(j);
|
||||
// reload from the snapshot: baselines still match
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let p = c.forget_local_plan(NOW, 30, &|_| false);
|
||||
assert_eq!(p.evict.len(), 5, "{p:?}");
|
||||
let ops = p.ops();
|
||||
for op in &ops {
|
||||
c.apply(op.clone()).unwrap();
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
let expect = c.to_snapshot();
|
||||
drop(j); // crash: replay the log
|
||||
let (_, c2, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!(r.failed, 0);
|
||||
assert_eq!(c2.to_snapshot(), expect);
|
||||
assert_eq!(c2.len(), 5);
|
||||
// nothing left to forget
|
||||
assert!(c2.forget_local_plan(NOW, 30, &|_| false).evict.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_photo_ids_cover_nested_ops() {
|
||||
let op = Op::Batch {
|
||||
ops: vec![
|
||||
Op::SetRating { id: PhotoId(1), rating: 1 },
|
||||
Op::SetAlbumPhotos { id: AlbumId(1), photos: vec![PhotoId(2), PhotoId(3)] },
|
||||
Op::Batch { ops: vec![Op::RemovePhoto { id: PhotoId(4) }] },
|
||||
],
|
||||
};
|
||||
let mut ids = Vec::new();
|
||||
op.photo_ids(&mut |id| ids.push(id.0));
|
||||
assert_eq!(ids, vec![1, 2, 3, 4]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Issue #99: one process per library.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::lock::{LOCK, OWNER};
|
||||
use crate::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(first.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
|
||||
|
||||
let e = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, std::process::id());
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
|
||||
|
||||
drop(first);
|
||||
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
|
||||
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
|
||||
let again = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap();
|
||||
assert!(again.held());
|
||||
drop(again);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
|
||||
#[test]
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(l.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
|
||||
#[test]
|
||||
fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
let dir = temp_dir("child");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args(["--exact", "tests_lock::lock_holder_child", "--ignored", "--test-threads=1"])
|
||||
.env("LC_LOCK_CHILD_DIR", &dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while !dir.join("child-locked").exists() {
|
||||
assert!(child.try_wait().unwrap().is_none(), "child exited before locking");
|
||||
assert!(std::time::Instant::now() < deadline, "child never locked");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, child.id());
|
||||
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
|
||||
|
||||
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
|
||||
child.wait().unwrap();
|
||||
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
|
||||
assert!(l.held());
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Run by [`lock_held_by_another_process_is_released_when_it_dies`] in a child process: take the
|
||||
/// lock, say so, and wait to be killed.
|
||||
#[test]
|
||||
#[ignore = "helper process for lock_held_by_another_process_is_released_when_it_dies"]
|
||||
fn lock_holder_child() {
|
||||
let Some(dir) = std::env::var_os("LC_LOCK_CHILD_DIR") else { return };
|
||||
let _l = LibraryLock::acquire(std::path::Path::new(&dir), "lightcraft-cli").unwrap();
|
||||
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(120));
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Issue #101: an append that fails part-way (disk full, a dropped network share) and is then
|
||||
//! retried must not read back as log damage, and must lose nothing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::journal::LOG;
|
||||
use crate::*;
|
||||
|
||||
/// A store whose next append writes only a prefix of its data, then fails.
|
||||
#[derive(Clone, Default)]
|
||||
struct Torn {
|
||||
files: MemStore,
|
||||
/// The next append writes this many bytes, then returns an error.
|
||||
torn_at: Arc<Mutex<Option<usize>>>,
|
||||
/// `truncate` fails (the cut-back after a failed append can't be done right away).
|
||||
truncate_fails: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Torn {
|
||||
fn tear_next_append(&self, bytes: usize) {
|
||||
*self.torn_at.lock().unwrap() = Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Torn {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(n) = self.torn_at.lock().unwrap().take() {
|
||||
self.files.append(name, &data[..n.min(data.len())])?;
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
if self.truncate_fails.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("share unavailable"));
|
||||
}
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"torn".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn add_photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// Apply `ops` to the live catalog (like a command) and return them for the journal.
|
||||
fn apply(c: &mut Catalog, ops: Vec<Op>) -> Vec<Op> {
|
||||
for op in &ops {
|
||||
c.apply(op.clone()).unwrap();
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Every tear position of a batch, then the retry (same ops, same seqs, like the engine's queue)
|
||||
/// and more appends: reloading replays everything, reports no damage and no torn bytes.
|
||||
#[test]
|
||||
fn partial_append_then_retry_loads_cleanly() {
|
||||
let probe = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(probe.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
|
||||
let before = probe.get(LOG).unwrap().len();
|
||||
j.append(&batch).unwrap();
|
||||
let batch_len = probe.get(LOG).unwrap().len() - before;
|
||||
|
||||
for tear in 1..batch_len {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good_len = t.files.get(LOG).unwrap().len();
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
|
||||
t.tear_next_append(tear);
|
||||
assert!(j.append(&batch).is_err(), "tear at {tear}");
|
||||
// cut back to the last whole record
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len, "tear at {tear}");
|
||||
j.append(&batch).unwrap();
|
||||
let more = apply(&mut c, vec![Op::SetLabel { id, label: Some(ColorLabel::ALL[0]) }]);
|
||||
j.append(&more).unwrap();
|
||||
drop(j);
|
||||
|
||||
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
|
||||
assert_eq!((r.torn_bytes, r.torn_fragments, r.replayed), (0, 0, 5), "tear at {tear}");
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
|
||||
}
|
||||
}
|
||||
|
||||
/// When cutting back fails too (the share is gone), the next append cuts back first; until that
|
||||
/// works nothing more is appended after the fragment.
|
||||
#[test]
|
||||
fn failed_cut_back_is_done_before_the_next_append() {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good_len = t.files.get(LOG).unwrap().len();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetRating { id, rating: 5 }]);
|
||||
t.truncate_fails.store(true, Ordering::SeqCst);
|
||||
t.tear_next_append(30);
|
||||
assert!(j.append(&batch).is_err());
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30, "the fragment is still there");
|
||||
// still unreachable: the retry fails without writing anything after the fragment
|
||||
assert!(j.append(&batch).is_err());
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30);
|
||||
t.truncate_fails.store(false, Ordering::SeqCst);
|
||||
j.append(&batch).unwrap();
|
||||
drop(j);
|
||||
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert!(r.damaged.is_none(), "{r:?}");
|
||||
assert_eq!(r.replayed, 3);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// Logs written before the fix: the fragment of the failed append is glued to the first retried
|
||||
/// record on one line, followed by the rest of the retry. They load in full (the fragment is
|
||||
/// skipped), instead of stopping at the fragment and setting the later records aside as damage.
|
||||
#[test]
|
||||
fn legacy_fragment_glued_to_the_retry_is_skipped() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good = m.get(LOG).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(
|
||||
&mut c,
|
||||
vec![
|
||||
Op::SetRating { id, rating: 3 },
|
||||
Op::SetFlag { id, flag: Flag::Pick },
|
||||
Op::SetLabelName { label: ColorLabel::ALL[1], name: Some("Größe ✓".into()) },
|
||||
],
|
||||
);
|
||||
j.append(&batch).unwrap();
|
||||
let full = m.get(LOG).unwrap();
|
||||
let batch_bytes = &full[good.len()..];
|
||||
let later = apply(&mut c, vec![Op::SetRating { id, rating: 1 }]);
|
||||
j.append(&later).unwrap();
|
||||
let later_bytes = m.get(LOG).unwrap()[full.len()..].to_vec();
|
||||
drop(j);
|
||||
|
||||
// every tear (inside the first record, after it, inside a multi-byte character…)
|
||||
for tear in 1..batch_bytes.len() {
|
||||
if batch_bytes[tear - 1] == b'\n' {
|
||||
continue; // a tear on a record boundary leaves no fragment
|
||||
}
|
||||
let mut log = good.clone();
|
||||
log.extend_from_slice(&batch_bytes[..tear]);
|
||||
log.extend_from_slice(batch_bytes);
|
||||
log.extend_from_slice(&later_bytes);
|
||||
let t = MemStore::new();
|
||||
t.set(LOG, log);
|
||||
let (_, loaded, r) = Journal::open(Box::new(t)).unwrap();
|
||||
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
|
||||
assert_eq!(r.torn_fragments, 1, "tear at {tear}");
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
|
||||
}
|
||||
|
||||
// a damaged record whose tail happens to be a whole record from the future is still damage
|
||||
let mut log = good.clone();
|
||||
log.extend_from_slice(b"garbage");
|
||||
log.extend_from_slice(&later_bytes);
|
||||
log.extend_from_slice(&later_bytes);
|
||||
let t = MemStore::new();
|
||||
t.set(LOG, log);
|
||||
let (_, _, r) = Journal::open(Box::new(t)).unwrap();
|
||||
assert!(r.damaged.is_some(), "{r:?}");
|
||||
}
|
||||
|
||||
/// A snapshot can absorb ops whose append failed: it records them as saved (`seq` moves past
|
||||
/// them), and appending after it continues from there.
|
||||
#[test]
|
||||
fn snapshot_with_unlogged_ops_saves_them() {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let queued = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetFlag { id, flag: Flag::Reject }]);
|
||||
t.tear_next_append(10);
|
||||
assert!(j.append(&queued).is_err());
|
||||
j.snapshot_with_unlogged(&c, queued.len() as u64).unwrap();
|
||||
assert_eq!(j.seq(), 3);
|
||||
let after = apply(&mut c, vec![Op::SetRating { id, rating: 5 }]);
|
||||
j.append(&after).unwrap();
|
||||
drop(j);
|
||||
let (j, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (3, 1));
|
||||
assert_eq!(j.seq(), 4);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// `FsStore::truncate` on a file that was never created (an append that failed first) is a no-op.
|
||||
#[test]
|
||||
fn fs_store_truncates_a_missing_file_quietly() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-torn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.truncate(LOG, 0).unwrap();
|
||||
assert!(!dir.join(LOG).exists());
|
||||
s.append(LOG, b"abc\n").unwrap();
|
||||
s.truncate(LOG, 0).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(LOG)).unwrap(), b"");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
|
||||
}
|
||||
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
|
||||
let px = samples
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let k = 1.0 - c[3];
|
||||
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
|
||||
let n = img.width as usize * img.height as usize;
|
||||
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
|
||||
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
|
||||
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
|
||||
2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
|
||||
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
|
||||
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
|
||||
};
|
||||
@@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
}};
|
||||
}
|
||||
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
|
||||
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
}
|
||||
match img.samples {
|
||||
Samples::U8(s) => match ch {
|
||||
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize
|
||||
for (s, d) in src_chunks.zip(dst_chunks) {
|
||||
t.transform(s, d).ok()?;
|
||||
}
|
||||
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect())
|
||||
};
|
||||
std::panic::catch_unwind(run).ok().flatten()
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
let (model, buf, depth) = match info.pixel_format {
|
||||
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
|
||||
PixelFormat::L16 => {
|
||||
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
(Model::Gray, Buf::U16(v), 16)
|
||||
}
|
||||
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
|
||||
@@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
|
||||
let Some(list) = t.bytes(e) else { return vec![] };
|
||||
let mut out = Vec::new();
|
||||
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
|
||||
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
|
||||
if i == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ pub fn encode(
|
||||
{
|
||||
let rows: Vec<(usize, Vec<&mut [f32]>)> = {
|
||||
let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect();
|
||||
(0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect()
|
||||
(0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::<Option<_>>()?))).collect()
|
||||
};
|
||||
let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| {
|
||||
let src = &data[y * width * channels..(y + 1) * width * channels];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
pub mod encode;
|
||||
@@ -216,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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,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,
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
};
|
||||
buf.truncate(out.buffer_size());
|
||||
let (samples, depth) = match out.bit_depth {
|
||||
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
|
||||
png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
|
||||
_ => (Buf::U8(buf), info.bit_depth as u8),
|
||||
};
|
||||
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//!
|
||||
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cct;
|
||||
pub mod perceptual;
|
||||
@@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020;
|
||||
|
||||
impl RgbSpace {
|
||||
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
|
||||
/// Degenerate (collinear) primaries, which readers reject before building a space from a
|
||||
/// file, leave the primaries unscaled instead of panicking.
|
||||
pub fn to_xyz(&self) -> Mat3 {
|
||||
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
|
||||
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
|
||||
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
|
||||
let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz()));
|
||||
m.mul(&Mat3::diag(s[0], s[1], s[2]))
|
||||
}
|
||||
/// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries.
|
||||
pub fn from_xyz(&self) -> Mat3 {
|
||||
self.to_xyz().inverse().expect("invertible")
|
||||
self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY)
|
||||
}
|
||||
/// Luminance weights (the Y row of `to_xyz`).
|
||||
pub fn luma(&self) -> [f64; 3] {
|
||||
@@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||
}
|
||||
let s = BRADFORD.apply(src.to_xyz());
|
||||
let d = BRADFORD.apply(dst.to_xyz());
|
||||
let inv = BRADFORD.inverse().expect("invertible");
|
||||
// a constant, invertible matrix: the fallback (no adaptation) can't happen
|
||||
let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY };
|
||||
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,10 @@ fn mats() -> &'static OkMats {
|
||||
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
|
||||
OkMats {
|
||||
to_lms: to_lms.to_f32(),
|
||||
from_lms: to_lms.inverse().expect("invertible").to_f32(),
|
||||
// constant, invertible matrices (round trips tested below): the identity fallback can't happen
|
||||
from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
|
||||
to_lab: LMS_TO_LAB.to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -4,13 +4,16 @@
|
||||
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
|
||||
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
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,
|
||||
|
||||
@@ -17,6 +17,7 @@ lightcraft-scenes = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-gpu = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
@@ -29,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());
|
||||
}
|
||||
}
|
||||
+177
-35
@@ -2,6 +2,11 @@
|
||||
//! The folder's photos are probed in place as `local` photos — only folder views list them —
|
||||
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
|
||||
//! makes them ordinary library photos.
|
||||
//!
|
||||
//! Each browse records when the folders it listed were browsed. Untouched records of folders
|
||||
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
|
||||
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
|
||||
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -35,12 +40,113 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
let shown = s.visible().len();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
|
||||
}
|
||||
|
||||
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
|
||||
/// growing the log; far finer than the days the eviction counts in).
|
||||
const RESTAMP_SECS: i64 = 3600;
|
||||
|
||||
/// Record that `root` and the folders of `files` were browsed now (journaled, not an undo step).
|
||||
fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
|
||||
let now = (s.clock)();
|
||||
let now_s = lightcraft_catalog::stacks::iso_seconds(&now);
|
||||
let mut folders: std::collections::BTreeSet<String> =
|
||||
files.iter().filter_map(|f| Path::new(f).parent().map(|d| d.to_string_lossy().trim_end_matches(['/', '\\']).to_string())).collect();
|
||||
folders.insert(root.to_string());
|
||||
let ops: Vec<Op> = folders
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
let at = s.catalog.last_browsed(f).and_then(lightcraft_catalog::stacks::iso_seconds);
|
||||
match (at, now_s) {
|
||||
(Some(at), Some(n)) => n - at >= RESTAMP_SECS || n < at,
|
||||
_ => true,
|
||||
}
|
||||
})
|
||||
.map(|folder| Op::SetBrowsed { folder, at: Some(now.clone()) })
|
||||
.collect();
|
||||
if !ops.is_empty() {
|
||||
let op = Op::Batch { ops };
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
|
||||
/// referred to by an undo / redo step.
|
||||
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
|
||||
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
|
||||
keep.extend(s.selection.ids.iter().copied());
|
||||
keep.extend(s.selection.active);
|
||||
keep.extend(s.previous_active);
|
||||
keep.extend(s.interaction.as_ref().map(|i| i.photo));
|
||||
keep.extend(s.before.keys().copied());
|
||||
for e in s.undo.iter().chain(s.redo.iter()) {
|
||||
e.op.photo_ids(&mut |id| {
|
||||
keep.insert(id);
|
||||
});
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Forget untouched Local records whose folder wasn't browsed for `days` (default
|
||||
/// [`Session::forget_local_days`]; 0 = never). With `dry_run`, only report what would go.
|
||||
/// Journaled (durable, replayed after a crash) but not an undo step; nothing on disk changes.
|
||||
pub fn forget_local(&mut self, dry_run: bool, days: Option<u32>) -> lightcraft_catalog::ForgetPlan {
|
||||
let days = days.unwrap_or(self.forget_local_days);
|
||||
let now = (self.clock)();
|
||||
let keep = in_use(self);
|
||||
let smart = self.media.smart_dir.clone();
|
||||
let plan = self
|
||||
.catalog
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
|
||||
if dry_run {
|
||||
return plan;
|
||||
}
|
||||
for op in plan.ops() {
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(_) => self.pending_log.push(op),
|
||||
Err(e) => log::warn!("forget Local records: {e}"),
|
||||
}
|
||||
}
|
||||
if !plan.evict.is_empty() {
|
||||
log::info!("forgot {} untouched Local record(s) of folders not browsed for {days} days", plan.evict.len());
|
||||
}
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dry_run = bool_or(p, "dryRun", false);
|
||||
let days = match p.get("days") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
Some(v.as_u64().ok_or_else(|| bad("library.forgetLocal", "days must be a whole number (0 = never)"))?.min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
};
|
||||
let used = days.unwrap_or(s.forget_local_days);
|
||||
let plan = s.forget_local(dry_run, days);
|
||||
if !dry_run && !plan.evict.is_empty() {
|
||||
s.compact_soon();
|
||||
}
|
||||
Ok(json!({
|
||||
"dryRun": dry_run,
|
||||
"days": used,
|
||||
"local": plan.local,
|
||||
"forgotten": plan.evict.len(),
|
||||
"keptRecent": plan.kept_recent,
|
||||
"keptTouched": plan.kept_touched,
|
||||
"keptInUse": plan.kept_in_use,
|
||||
"foldersStamped": plan.stamp.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The browsed photos among `ids` (default: the selection) become library photos.
|
||||
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
@@ -60,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -78,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -98,62 +204,98 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
browse
|
||||
),
|
||||
cmd!(
|
||||
"library.forgetLocal",
|
||||
"Forget Unchanged Local Photos",
|
||||
[],
|
||||
None,
|
||||
"{dryRun?: bool, days?: n (default: the library preference, 0 = never)} — remove Local (browsed, not added) photos nobody changed, of folders not browsed for `days`, from the catalog; files and sidecars stay, browsing the folder again brings them back; not an undo step → {local, forgotten, keptRecent, keptTouched, keptInUse, foldersStamped, days, dryRun}",
|
||||
always,
|
||||
forget_local
|
||||
),
|
||||
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
|
||||
]
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
|
||||
/// point the photos back at a folder that no longer exists.
|
||||
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
use std::path::Path;
|
||||
/// Rename `from` to `to` on disk (a folder; everything in it goes along). Never overwrites:
|
||||
/// refused when `to` exists (a case-only rename on a case-insensitive file system goes through
|
||||
/// a temporary name). Missing parents of `to` are created.
|
||||
pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
return Err(format!("{from} is not a folder (moved or deleted?)"));
|
||||
}
|
||||
if dst.exists() {
|
||||
let case_only = from != to && from.to_lowercase() == to.to_lowercase();
|
||||
if dst.exists() && !case_only {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
if dst.starts_with(src) {
|
||||
return Err("a folder can't move into itself".into());
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
let mut n = 0;
|
||||
let ops: Vec<lightcraft_catalog::Op> = s
|
||||
if case_only {
|
||||
let tmp = src.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
if tmp.exists() {
|
||||
return Err(format!("{} already exists", tmp.display()));
|
||||
}
|
||||
std::fs::rename(src, &tmp).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
return std::fs::rename(&tmp, dst).map_err(|e| {
|
||||
let _ = std::fs::rename(&tmp, src);
|
||||
format!("{from} → {to}: {e}")
|
||||
});
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
|
||||
{
|
||||
b.path = Path::new(to).join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside, as one undo step: undo renames the folder back and restores the photos'
|
||||
/// paths, redo repeats the move — each refused (and reported) when the destination is taken.
|
||||
pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, file_name, np)| {
|
||||
n += 1;
|
||||
lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
}
|
||||
.map(|(id, file_name, np)| Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
})
|
||||
.collect();
|
||||
for op in ops {
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
// a browsed folder at or below it follows
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
|
||||
{
|
||||
b.path = dst.join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
let n = ops.len();
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
let folder = crate::FolderMove { from: to.to_string(), to: from.to_string() };
|
||||
if let Err(e) = s.commit_with_folder(&label, Op::Batch { ops }, folder) {
|
||||
// the catalog didn't take it: put the folder back where the photos still point
|
||||
let back = rename_folder_on_disk(to, from).err().map(|b| format!("; moving it back failed too: {b}")).unwrap_or_default();
|
||||
return Err(format!("{e}{back}"));
|
||||
}
|
||||
follow_folder(s, from, to);
|
||||
Ok(n)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -10,24 +10,28 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
|
||||
fn dng_path(path: &str) -> String {
|
||||
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
|
||||
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let dir = p.parent().unwrap_or(Path::new(""));
|
||||
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let mut out = dir.join(format!("{stem}.dng"));
|
||||
let mut n = 2;
|
||||
while out.exists() {
|
||||
out = dir.join(format!("{stem}-{n}.dng"));
|
||||
n += 1;
|
||||
}
|
||||
out.to_string_lossy().to_string()
|
||||
(1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) })
|
||||
}
|
||||
|
||||
/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path.
|
||||
/// An empty `packet` writes no XMP.
|
||||
/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name;
|
||||
/// returns the new path. An empty `packet` writes no XMP.
|
||||
///
|
||||
/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as
|
||||
/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is
|
||||
/// 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}"))?,
|
||||
};
|
||||
@@ -35,9 +39,21 @@ pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::res
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out = dng_path(path);
|
||||
std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?;
|
||||
Ok(out)
|
||||
let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?;
|
||||
let same = match (&back.data, &raw.data) {
|
||||
(lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b,
|
||||
(lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same {
|
||||
return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept"));
|
||||
}
|
||||
drop((raw, back));
|
||||
let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng)
|
||||
.map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?;
|
||||
Ok(out.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -133,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> {
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
// 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(())
|
||||
};
|
||||
@@ -148,6 +165,51 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": out, "id": new, "original": id.0}))
|
||||
}
|
||||
|
||||
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
|
||||
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
|
||||
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
|
||||
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
|
||||
let same = info.content_hash == ph.content_hash
|
||||
&& info.file_size == ph.file_size
|
||||
&& (info.width, info.height) == (ph.width, ph.height)
|
||||
&& info.preview_only == ph.preview_only;
|
||||
(!same).then_some(Op::SetContent {
|
||||
id,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
file_size: info.file_size,
|
||||
content_hash: info.content_hash,
|
||||
preview_only: info.preview_only,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -164,12 +226,21 @@ 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 };
|
||||
if info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) {
|
||||
// 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::SetContent { id, width: info.width, height: info.height, file_size: info.file_size, content_hash: info.content_hash });
|
||||
};
|
||||
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()));
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
reloaded.push(id);
|
||||
@@ -243,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) → {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
|
||||
),
|
||||
@@ -260,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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
|
||||
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
|
||||
//! exported as small JSON files).
|
||||
|
||||
use lightcraft_develop::ToneCurve;
|
||||
use lightcraft_geom::Point;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Channels `curve.reset` accepts.
|
||||
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
|
||||
|
||||
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
|
||||
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
|
||||
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
|
||||
let d = ToneCurve::default();
|
||||
match channel {
|
||||
"all" => *c = d,
|
||||
"point" => {
|
||||
c.master.clear();
|
||||
c.red.clear();
|
||||
c.green.clear();
|
||||
c.blue.clear();
|
||||
}
|
||||
"parametric" => {
|
||||
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
|
||||
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
|
||||
}
|
||||
"master" | "rgb" | "luma" => c.master.clear(),
|
||||
"red" => c.red.clear(),
|
||||
"green" => c.green.clear(),
|
||||
"blue" => c.blue.clear(),
|
||||
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.reset";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let channel = str_param(p, "channel").unwrap_or("all");
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
|
||||
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
|
||||
s.set_develop(id, d, &label)?;
|
||||
Ok(json!({"channel": channel}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"curve.reset",
|
||||
"Reset Tone Curve",
|
||||
[],
|
||||
None,
|
||||
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
|
||||
has_active,
|
||||
reset
|
||||
),
|
||||
cmd!(
|
||||
query "curve.presets",
|
||||
"Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"curve.applyPreset",
|
||||
"Apply Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
|
||||
has_active,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.savePreset",
|
||||
"Save Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
|
||||
has_active,
|
||||
save_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.deletePreset",
|
||||
"Delete Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — a user preset (built-ins can't be deleted)",
|
||||
always,
|
||||
delete_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.importPresets",
|
||||
"Import Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
|
||||
always,
|
||||
import_presets
|
||||
),
|
||||
cmd!(
|
||||
"curve.exportPresets",
|
||||
"Export Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
|
||||
always,
|
||||
export_presets
|
||||
),
|
||||
]
|
||||
}
|
||||
// ---------------------------------------------------------------- point-curve presets
|
||||
|
||||
/// File extension of exported curve presets (JSON).
|
||||
pub const CURVE_PRESET_EXT: &str = "lccurve";
|
||||
/// `format` tag of an exported curve-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.curvePresets";
|
||||
|
||||
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
|
||||
/// Built-ins ship with the app; the user's are saved with the library preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CurvePreset {
|
||||
pub name: String,
|
||||
pub master: Vec<[f64; 2]>,
|
||||
pub red: Vec<[f64; 2]>,
|
||||
pub green: Vec<[f64; 2]>,
|
||||
pub blue: Vec<[f64; 2]>,
|
||||
/// Built-in presets can't be deleted or overwritten (never stored).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
|
||||
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let mut out: Vec<[f64; 2]> =
|
||||
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
|
||||
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
|
||||
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
|
||||
out.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
|
||||
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
|
||||
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
impl CurvePreset {
|
||||
/// The point curves of `c` under `name`.
|
||||
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
|
||||
CurvePreset {
|
||||
name: name.into(),
|
||||
master: from_points(&c.master),
|
||||
red: from_points(&c.red),
|
||||
green: from_points(&c.green),
|
||||
blue: from_points(&c.blue),
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
|
||||
pub fn apply(&self, c: &mut ToneCurve) {
|
||||
c.master = to_points(&self.master);
|
||||
c.red = to_points(&self.red);
|
||||
c.green = to_points(&self.green);
|
||||
c.blue = to_points(&self.blue);
|
||||
}
|
||||
/// Does `c` carry exactly this preset's point curves?
|
||||
pub fn matches(&self, c: &ToneCurve) -> bool {
|
||||
let same = |a: &[[f64; 2]], b: &[Point]| {
|
||||
let (a, b) = (normalize(a), from_points(b));
|
||||
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
|
||||
};
|
||||
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
|
||||
}
|
||||
fn is_linear(&self) -> bool {
|
||||
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
|
||||
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
|
||||
pub fn builtin_presets() -> Vec<CurvePreset> {
|
||||
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
|
||||
vec![
|
||||
s("Linear", Vec::new()),
|
||||
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
|
||||
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Built-in presets, then the user's.
|
||||
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
|
||||
let mut v = builtin_presets();
|
||||
v.extend(s.curve_presets.iter().cloned());
|
||||
v
|
||||
}
|
||||
|
||||
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
|
||||
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
|
||||
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
|
||||
}
|
||||
|
||||
fn is_builtin_name(name: &str) -> bool {
|
||||
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) a user preset.
|
||||
fn upsert(s: &mut Session, p: CurvePreset) {
|
||||
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
|
||||
Some(x) => *x = p,
|
||||
None => s.curve_presets.push(p),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
|
||||
json!({"presets": all_presets(s), "current": current})
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.applyPreset";
|
||||
let name = name_param(p, C)?;
|
||||
let preset =
|
||||
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
preset.apply(&mut d.curve);
|
||||
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
|
||||
Ok(json!({"name": preset.name}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.savePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
|
||||
}
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
|
||||
if preset.is_linear() {
|
||||
return Err(bad(C, "the point curve is linear: shape it first"));
|
||||
}
|
||||
upsert(s, preset);
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.deletePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, "built-in presets can't be deleted"));
|
||||
}
|
||||
let n = s.curve_presets.len();
|
||||
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if n == s.curve_presets.len() {
|
||||
return Err(bad(C, format!("no curve preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let items = match &v {
|
||||
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
|
||||
Value::Array(_) => v.clone(),
|
||||
Value::Object(_) => Value::Array(vec![v.clone()]),
|
||||
_ => return Err("not a curve preset file".into()),
|
||||
};
|
||||
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no curve presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The JSON of a curve-preset file holding `presets`.
|
||||
pub fn to_file(presets: &[CurvePreset]) -> String {
|
||||
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
|
||||
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.importPresets";
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(x) = str_param(p, "path") {
|
||||
paths.push(x.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "missing `path` / `paths`"));
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for f in &paths {
|
||||
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
|
||||
Ok(list) => {
|
||||
for mut preset in list {
|
||||
preset.name = preset.name.trim().to_string();
|
||||
preset.builtin = false;
|
||||
if is_builtin_name(&preset.name) {
|
||||
preset.name = format!("{} (imported)", preset.name);
|
||||
}
|
||||
imported.push(preset.name.clone());
|
||||
upsert(s, preset);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
if !imported.is_empty() {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.exportPresets";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let mut path = std::path::PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(CURVE_PRESET_EXT);
|
||||
}
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let chosen: Vec<CurvePreset> = match &names {
|
||||
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
|
||||
None => s.curve_presets.clone(),
|
||||
};
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -96,5 +96,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
save
|
||||
),
|
||||
cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete),
|
||||
cmd!(
|
||||
query "export.checkTarget",
|
||||
"Check Output Path",
|
||||
[],
|
||||
None,
|
||||
"{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,111 @@
|
||||
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
||||
//! metadata, albums, import.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
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()
|
||||
}
|
||||
),
|
||||
@@ -250,13 +392,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
||||
Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default())
|
||||
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
||||
}),
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
|
||||
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
|
||||
"copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url,
|
||||
"creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"keywords": m.keywords});
|
||||
s.meta_clipboard = Some(v.clone());
|
||||
Ok(v)
|
||||
@@ -266,7 +409,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste Metadata",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, fields?: [title|caption|copyright|creator|location|keywords] (default: all copied)}",
|
||||
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "photo.pasteMetadata";
|
||||
@@ -346,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 }))?;
|
||||
@@ -353,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 })?;
|
||||
@@ -370,7 +573,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Info",
|
||||
[],
|
||||
None,
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let strs =
|
||||
@@ -390,6 +593,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
|
||||
};
|
||||
let status = match str_param(p, "copyrightStatus") {
|
||||
Some(t) => Some(
|
||||
CopyrightStatus::parse(t)
|
||||
.ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
@@ -397,10 +607,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(g) = gps {
|
||||
m.gps = g;
|
||||
}
|
||||
if let Some(st) = status {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
for (k, field) in [
|
||||
("title", &mut m.title),
|
||||
("caption", &mut m.caption),
|
||||
("copyright", &mut m.copyright),
|
||||
("usageTerms", &mut m.usage_terms),
|
||||
("copyrightUrl", &mut m.copyright_url),
|
||||
("creator", &mut m.creator),
|
||||
("location", &mut m.location),
|
||||
("city", &mut m.city),
|
||||
@@ -644,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
|
||||
@@ -657,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);
|
||||
@@ -688,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
|
||||
@@ -718,7 +939,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Review Import",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added",
|
||||
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -732,76 +953,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"library.import",
|
||||
"Add Photos…",
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
"{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,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
|
||||
};
|
||||
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)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
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),
|
||||
..Default::default()
|
||||
};
|
||||
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() {
|
||||
@@ -845,6 +1011,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
// settings files that were unreadable or damaged at open (kept, defaults used)
|
||||
"settingsWarnings": lib.settings_warnings,
|
||||
// changes applied in memory whose write failed (retried by every save)
|
||||
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
|
||||
"unsavedError": s.unsaved().map(|u| u.1),
|
||||
// untouched Local records forgotten when the library opened
|
||||
"forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})),
|
||||
"persistence": j.stats(),
|
||||
"cache": cache,
|
||||
"load": {
|
||||
"created": lib.report.created,
|
||||
@@ -860,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()
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -59,9 +59,103 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸
|
||||
/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's
|
||||
/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them.
|
||||
fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
use lightcraft_meta::{DateTime, Match, parse_gpx};
|
||||
const C: &str = "photo.autoTagTracklog";
|
||||
let text = match (str_param(p, "gpx"), str_param(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?,
|
||||
(None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")),
|
||||
};
|
||||
let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?;
|
||||
// minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC)
|
||||
let offset: Option<i64> = match p.get("offset") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(o)) if o.trim().is_empty() => None,
|
||||
Some(Value::Number(h)) => {
|
||||
let h = h.as_f64().unwrap_or(0.0);
|
||||
if h.abs() > 18.0 {
|
||||
return Err(bad(C, "offset is at most ±18 hours"));
|
||||
}
|
||||
Some((h * 60.0).round() as i64)
|
||||
}
|
||||
Some(Value::String(o)) => {
|
||||
Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64)
|
||||
}
|
||||
Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")),
|
||||
};
|
||||
let max_gap = f64_or(p, "maxGap", 600.0);
|
||||
if max_gap.is_nan() || max_gap < 0.0 {
|
||||
return Err(bad(C, "maxGap must be ≥ 0 seconds"));
|
||||
}
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||||
let mut ops = Vec::new();
|
||||
let mut photos = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
if ph.meta.gps.is_some() && !replace {
|
||||
has_gps += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else {
|
||||
no_time += 1;
|
||||
continue;
|
||||
};
|
||||
// UTC seconds: the recorded zone wins, else the caller's offset
|
||||
let utc = match dt.offset_minutes {
|
||||
Some(_) => dt.unix_seconds(),
|
||||
None => {
|
||||
if offset.is_none() {
|
||||
assumed_utc += 1;
|
||||
}
|
||||
dt.unix_seconds() - offset.unwrap_or(0) * 60
|
||||
}
|
||||
} as f64
|
||||
+ dt.millis as f64 / 1000.0;
|
||||
let Some((g, m)) = log.locate(utc, max_gap) else {
|
||||
outside += 1;
|
||||
continue;
|
||||
};
|
||||
if m == Match::Interpolated {
|
||||
interpolated += 1;
|
||||
}
|
||||
let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7);
|
||||
photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }}));
|
||||
if ph.meta.gps != Some(pos) {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.gps = Some(pos);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
}
|
||||
let tagged = photos.len();
|
||||
if !bool_or(p, "dryRun", false) && !ops.is_empty() {
|
||||
s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?;
|
||||
}
|
||||
let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64));
|
||||
Ok(json!({
|
||||
"tagged": tagged,
|
||||
"interpolated": interpolated,
|
||||
"nearest": tagged - interpolated,
|
||||
"skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps},
|
||||
"assumedUtc": assumed_utc,
|
||||
"points": log.points.len(),
|
||||
"start": iso(start),
|
||||
"end": iso(end),
|
||||
"photos": photos,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||||
Ok(crate::rename::token_help_json())
|
||||
}),
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||||
}),
|
||||
@@ -70,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Photos",
|
||||
[],
|
||||
None,
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||||
@@ -127,6 +221,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n, "captured": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.autoTagTracklog",
|
||||
"Auto-Tag Photos from Tracklog",
|
||||
[],
|
||||
None,
|
||||
"{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step",
|
||||
has_selection,
|
||||
auto_tag_tracklog
|
||||
),
|
||||
cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| {
|
||||
Ok(json!(ColorLabel::ALL
|
||||
.iter()
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| bad(id, e.to_string()))?;
|
||||
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
s.check_write_target(path).map_err(|e| bad(id, e))?;
|
||||
crate::export::write_file(path, &png).map_err(|e| bad(id, e))?;
|
||||
info["previewPath"] = json!(path);
|
||||
}
|
||||
if let Some(img) = &out.preview {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…)
|
||||
//! applied to photos in one step or to every import (Settings → Import). Saved with the library.
|
||||
|
||||
use lightcraft_catalog::Meta;
|
||||
use lightcraft_catalog::{CopyrightStatus, Meta};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -9,7 +9,20 @@ use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The fields a metadata preset can hold (photo.setMeta names).
|
||||
pub const FIELDS: &[&str] = &["title", "caption", "copyright", "creator", "location", "city", "state", "country", "keywords"];
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"title",
|
||||
"caption",
|
||||
"copyright",
|
||||
"copyrightStatus",
|
||||
"usageTerms",
|
||||
"copyrightUrl",
|
||||
"creator",
|
||||
"location",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"keywords",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MetadataPreset {
|
||||
@@ -23,6 +36,8 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
"title" => &mut m.title,
|
||||
"caption" => &mut m.caption,
|
||||
"copyright" => &mut m.copyright,
|
||||
"usageTerms" => &mut m.usage_terms,
|
||||
"copyrightUrl" => &mut m.copyright_url,
|
||||
"creator" => &mut m.creator,
|
||||
"location" => &mut m.location,
|
||||
"city" => &mut m.city,
|
||||
@@ -35,7 +50,11 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
/// Apply preset `fields` to `m`: text fields replace, keywords are added.
|
||||
pub fn apply_to(m: &mut Meta, fields: &Value) {
|
||||
for (k, v) in fields.as_object().into_iter().flatten() {
|
||||
if k == "keywords" {
|
||||
if k == "copyrightStatus" {
|
||||
if let Some(st) = v.as_str().and_then(CopyrightStatus::parse) {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
} else if k == "keywords" {
|
||||
for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) {
|
||||
m.keywords.push(kw.to_string());
|
||||
@@ -59,6 +78,9 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
|
||||
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
|
||||
}
|
||||
if let Some(v) = o.get("copyrightStatus").filter(|v| v.as_str().and_then(CopyrightStatus::parse).is_none()) {
|
||||
return Err(bad(C, format!("copyrightStatus {v}: unknown, copyrighted or publicDomain")));
|
||||
}
|
||||
Value::Object(o.clone())
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`fields` must be an object")),
|
||||
@@ -66,17 +88,24 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => {
|
||||
let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?;
|
||||
let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone();
|
||||
let only: Vec<String> = p
|
||||
.get("only")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_else(|| ["copyright", "creator", "location", "city", "state", "country"].map(String::from).to_vec());
|
||||
let only: Vec<String> =
|
||||
p.get("only").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_else(
|
||||
|| {
|
||||
["copyright", "copyrightStatus", "usageTerms", "copyrightUrl", "creator", "location", "city", "state", "country"]
|
||||
.map(String::from)
|
||||
.to_vec()
|
||||
},
|
||||
);
|
||||
let mut o = Map::new();
|
||||
for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) {
|
||||
if k == "keywords" {
|
||||
if !m.keywords.is_empty() {
|
||||
o.insert(k.clone(), json!(m.keywords));
|
||||
}
|
||||
} else if k == "copyrightStatus" {
|
||||
if !m.copyright_status.is_unknown() {
|
||||
o.insert(k.clone(), json!(m.copyright_status.id()));
|
||||
}
|
||||
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
|
||||
o.insert(k.clone(), json!(v.clone()));
|
||||
}
|
||||
@@ -136,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Metadata Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, fields?: {copyright?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default copyright, creator, place)} — adds or replaces",
|
||||
"{name, fields?: {copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default the copyright fields, creator, place)} — adds or replaces",
|
||||
always,
|
||||
save
|
||||
),
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
//! Missing originals: photos whose file is no longer where the library expects it (moved,
|
||||
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
|
||||
//! searching a folder for files with the same name and size (`library.findMissing`).
|
||||
//! searching a folder for files with the same name and size, or the same content
|
||||
//! (`library.findMissing`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
|
||||
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
|
||||
///
|
||||
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
|
||||
/// them in the catalog, they never show in library views (Missing Photos included), and a
|
||||
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
|
||||
/// `library.missing` and Find Missing Photos all use this one rule.
|
||||
pub fn checked_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } if p.in_library() => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
|
||||
pub fn candidates(cat: &Catalog) -> Vec<String> {
|
||||
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// [`missing`] with the existence check supplied (tests count the file-system calls).
|
||||
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
|
||||
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
|
||||
}
|
||||
|
||||
/// Library photos whose original file can't be found: (id, path).
|
||||
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Vec::new();
|
||||
}
|
||||
s.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
missing_with(&s.catalog, |f| Path::new(f).exists())
|
||||
}
|
||||
|
||||
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
|
||||
/// 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 {
|
||||
@@ -42,53 +68,272 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "only photos from files can be relinked"));
|
||||
}
|
||||
let abs = abs.to_string_lossy().to_string();
|
||||
s.commit("Relink Photo", relink_op(id, &abs))?;
|
||||
// the file may differ from the one imported (another export, a raw that decodes now or not):
|
||||
// its size, dimensions and preview-only state follow it
|
||||
let mut ops = vec![relink_op(id, &abs)];
|
||||
if s.media.file_probe.is_some()
|
||||
&& let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id))
|
||||
{
|
||||
ops.extend(crate::cmd::convert::content_op(id, ph, info));
|
||||
}
|
||||
let op = match <[Op; 1]>::try_from(ops) {
|
||||
Ok([op]) => op,
|
||||
Err(ops) => Op::Batch { ops },
|
||||
};
|
||||
s.commit("Relink Photo", op)?;
|
||||
s.media.forget(id);
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
|
||||
/// library knows it — and relink every match in one undoable step.
|
||||
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`"))?;
|
||||
/// The stored content hash of a photo, without the `:dupN` suffix duplicates get.
|
||||
fn stored_hash(p: &Photo) -> Option<&str> {
|
||||
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// The content hash of the file at `path` (as import computes it), if it can be read.
|
||||
fn file_hash(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// 2. renamed: a file of the same size with the same content hash (only same-size files are
|
||||
/// read, so this stays cheap);
|
||||
/// 3. without a hash to confirm (or the file was edited in place): the one file with the same
|
||||
/// name and size. Several such files are ambiguous: the photo is skipped and reported in
|
||||
/// `ambiguous`, never guessed (two cameras' `IMG_0001.CR3` of one size are different photos).
|
||||
///
|
||||
/// Files that are already some photo's original, or that another photo was just matched with,
|
||||
/// are not used.
|
||||
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}));
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
// file name (lower case) → candidate paths
|
||||
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();
|
||||
for f in crate::import::expand(&[folder.to_string()], None) {
|
||||
if let Some(n) = Path::new(&f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
|
||||
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());
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
|
||||
let hit = by_name.get(&name).and_then(|c| {
|
||||
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
|
||||
});
|
||||
if let Some(path) = hit {
|
||||
ops.push(relink_op(*id, path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path}));
|
||||
let mut by_size: Option<HashMap<u64, Vec<String>>> = None;
|
||||
let mut hashes: HashMap<String, Option<String>> = HashMap::new();
|
||||
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 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(|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, 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
|
||||
&& size > 0
|
||||
{
|
||||
let sizes = by_size.get_or_insert_with(|| {
|
||||
let mut m: HashMap<u64, Vec<String>> = HashMap::new();
|
||||
for f in &files {
|
||||
if let Some(n) = size_of(f) {
|
||||
m.entry(n).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
hit = sizes
|
||||
.get(&size)
|
||||
.and_then(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
|
||||
.map(|f| (f.clone(), MatchBy::Content));
|
||||
}
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops })?;
|
||||
for f in &found {
|
||||
if let Some(id) = f["id"].as_u64() {
|
||||
s.media.forget(PhotoId(id));
|
||||
// 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.as_slice() {
|
||||
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
|
||||
[] => {}
|
||||
_ => {
|
||||
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 };
|
||||
used.insert(path.clone());
|
||||
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still}))
|
||||
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 {
|
||||
s.media.forget(f.id);
|
||||
}
|
||||
}
|
||||
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> {
|
||||
@@ -110,7 +355,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Missing Photos",
|
||||
[],
|
||||
None,
|
||||
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
|
||||
"{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
|
||||
),
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod before;
|
||||
mod browse;
|
||||
pub(crate) mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
mod library;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
mod masks;
|
||||
@@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(develop::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(curves::specs());
|
||||
v.extend(masks::specs());
|
||||
v.extend(organize::specs());
|
||||
v.extend(keywords::specs());
|
||||
|
||||
@@ -14,6 +14,7 @@ fn current(s: &Session) -> Value {
|
||||
"xmp": s.xmp,
|
||||
"import": s.import_defaults,
|
||||
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
|
||||
"forgetLocalDays": s.forget_local_days,
|
||||
"persistent": s.library.is_some(),
|
||||
})
|
||||
}
|
||||
@@ -86,6 +87,11 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
changed = true;
|
||||
}
|
||||
s.import_defaults = d;
|
||||
if let Some(v) = p.get("forgetLocalDays") {
|
||||
let days = v.as_u64().ok_or_else(|| bad(ID, "forgetLocalDays must be a whole number of days (0 = never)"))?;
|
||||
s.forget_local_days = days.min(36_500) as u32;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(mb) = p.get("cacheMb") {
|
||||
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
|
||||
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
|
||||
@@ -101,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Preferences",
|
||||
[],
|
||||
None,
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
|
||||
always,
|
||||
prefs
|
||||
)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset and zip bundles; export `.lcpreset`.
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\") and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -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,50 +107,205 @@ 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 removed, mut failed) = (0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
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;
|
||||
}
|
||||
if path.exists() {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string()));
|
||||
match r {
|
||||
Ok(()) => built += 1,
|
||||
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, "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
|
||||
/// used now (a chosen folder on an unplugged drive is `available: false`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn location_json(s: &Session) -> Value {
|
||||
let dir = s.media.smart_dir.clone();
|
||||
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir));
|
||||
let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats);
|
||||
json!({
|
||||
"path": dir.as_ref().map(|d| d.to_string_lossy()),
|
||||
"default": default.map(|d| d.to_string_lossy().to_string()),
|
||||
"custom": s.smart_previews_dir.is_some(),
|
||||
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(),
|
||||
"count": count,
|
||||
"bytes": bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Show or change where smart previews are kept (per library).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviewsLocation";
|
||||
let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
|
||||
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
|
||||
if !reset && chosen.is_none() {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let default = crate::smart::dir(&lib_dir);
|
||||
let (new_dir, custom) = match chosen {
|
||||
Some(x) if !reset => {
|
||||
let d = std::path::PathBuf::from(x.trim());
|
||||
if !d.is_absolute() {
|
||||
return Err(bad(C, "path must be an absolute folder path"));
|
||||
}
|
||||
(d.clone(), (d != default).then_some(d))
|
||||
}
|
||||
_ => (default, None),
|
||||
};
|
||||
if new_dir == current {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
// the new place must work before anything moves; there is no fallback to another drive
|
||||
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
|
||||
let (held, _) = crate::smart::stats(¤t);
|
||||
let existing = match str_param(p, "existing") {
|
||||
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
|
||||
None if held == 0 => Some(crate::smart::Existing::Leave),
|
||||
None => None,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display())));
|
||||
};
|
||||
let (moved, failed) = crate::smart::migrate(¤t, &new_dir, existing);
|
||||
s.smart_previews_dir = custom;
|
||||
s.media.smart_dir = Some(new_dir);
|
||||
s.save_prefs()?;
|
||||
let mut r = location_json(s);
|
||||
r["handled"] = json!(moved);
|
||||
r["failed"] = json!(failed);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Whether photo `id` has a smart preview.
|
||||
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
|
||||
match (&s.media.smart_dir, s.catalog.photo(id)) {
|
||||
(Some(dir), Some(p)) => dir.join(crate::smart::file_name(p)).exists(),
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -150,10 +318,20 @@ 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 → {built, 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
|
||||
),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cmd!(
|
||||
"library.smartPreviewsLocation",
|
||||
"Smart Previews Location",
|
||||
[],
|
||||
None,
|
||||
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}",
|
||||
always,
|
||||
smart_location
|
||||
),
|
||||
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
|
||||
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
|
||||
@@ -171,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, _| {
|
||||
|
||||
@@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
|
||||
"deleted": p.deleted,
|
||||
"copyOf": p.copy_of.map(|c| c.0),
|
||||
"copyName": p.copy_name,
|
||||
// an undecodable raw variant, shown and edited from its embedded JPEG: why
|
||||
"previewOnly": p.preview_only,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
|
||||
.collect();
|
||||
// XMP: the sidecar if there is one, else the file's own packet
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.xmp.naming).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let xmp: Vec<Value> = packet
|
||||
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
|
||||
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
|
||||
@@ -184,11 +186,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process)", always, |_, p| {
|
||||
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| {
|
||||
if let Some(on) = p.get("enabled").and_then(Value::as_bool) {
|
||||
lightcraft_gpu::set_enabled(on);
|
||||
}
|
||||
Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()}))
|
||||
Ok(json!({
|
||||
"enabled": lightcraft_gpu::enabled(),
|
||||
"available": lightcraft_gpu::available(),
|
||||
"adapter": lightcraft_gpu::adapter_name(),
|
||||
"reason": lightcraft_gpu::unavailable_reason(),
|
||||
"lastFallback": lightcraft_gpu::last_fallback(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| {
|
||||
Ok(serde_json::to_value(s.memory_report()).unwrap_or_default())
|
||||
|
||||
@@ -9,14 +9,26 @@ use crate::{Result, Session};
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut written = Vec::new();
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let owners = crate::sidecar::StemOwners::of(&s.catalog);
|
||||
for id in s.targets(p) {
|
||||
match s.save_sidecar(id) {
|
||||
Ok(path) => written.push(path.display().to_string()),
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
Ok(r) => {
|
||||
let path = r.path.display().to_string();
|
||||
if r.merged {
|
||||
merged.push(path.clone());
|
||||
}
|
||||
if let Some(b) = r.backup {
|
||||
backups.push(json!({"path": path, "backup": b.display().to_string()}));
|
||||
}
|
||||
written.push(path);
|
||||
}
|
||||
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
Ok(json!({"written": written, "failed": failed}))
|
||||
Ok(json!({"written": written, "merged": merged, "backups": backups, "failed": failed}))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Metadata to File",
|
||||
["Photo"],
|
||||
Some("Cmd+S"),
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original, merged into an existing one (other apps' data kept; an unreadable one is backed up first) → {written: [path], merged: [path], backups: [{path, backup}], failed}",
|
||||
has_selection,
|
||||
save
|
||||
),
|
||||
|
||||
+205
-22
@@ -1,6 +1,15 @@
|
||||
//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
|
||||
//! the import review copies from them into the library.
|
||||
//!
|
||||
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the
|
||||
//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background
|
||||
//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`]
|
||||
//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans
|
||||
//! synchronously for explicit requests (`library.devices`, Add from Device without a path).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -14,6 +23,9 @@ pub struct Device {
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// How long a scan is served before [`devices`] starts another one.
|
||||
pub const MAX_AGE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS`
|
||||
/// (paths joined like `PATH`) replaces them (tests, unusual mounts).
|
||||
fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
@@ -23,7 +35,9 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
out.push("/Volumes".into());
|
||||
} else if cfg!(target_os = "linux") {
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
// Linux desktops mount under /media/$USER or /run/media/$USER; FreeBSD's automounter
|
||||
// (automount/autofs, or a desktop's) under /media. Missing folders are skipped.
|
||||
if let Ok(user) = std::env::var("USER") {
|
||||
out.push(format!("/media/{user}").into());
|
||||
out.push(format!("/run/media/{user}").into());
|
||||
@@ -34,21 +48,114 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds:
|
||||
/// menus ask often, and listing mounts can be slow (network volumes).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = Mutex::new(None);
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((t, d)) = c.as_ref()
|
||||
&& t.elapsed() < Duration::from_secs(2)
|
||||
{
|
||||
return d.clone();
|
||||
type Scanner = dyn Fn() -> Vec<Device> + Send + Sync;
|
||||
type Notify = dyn Fn() + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatchState {
|
||||
list: Vec<Device>,
|
||||
/// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where
|
||||
/// there are no mounted volumes either.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
scanned: Option<std::time::Instant>,
|
||||
/// A background scan is running.
|
||||
refreshing: bool,
|
||||
}
|
||||
|
||||
/// A cached device list, refreshed off the calling thread.
|
||||
pub struct DeviceWatch {
|
||||
scanner: Box<Scanner>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
max_age: Duration,
|
||||
state: Mutex<WatchState>,
|
||||
notify: Mutex<Option<Arc<Notify>>>,
|
||||
}
|
||||
|
||||
impl DeviceWatch {
|
||||
pub fn new(scanner: impl Fn() -> Vec<Device> + Send + Sync + 'static, max_age: Duration) -> Arc<Self> {
|
||||
Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) })
|
||||
}
|
||||
let d = scan();
|
||||
*c = Some((Instant::now(), d.clone()));
|
||||
d
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, WatchState> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Called (on the scanning thread) whenever a scan changes the list.
|
||||
pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) {
|
||||
*self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f));
|
||||
}
|
||||
|
||||
/// The last scan, immediately; starts a background rescan when it is stale and none is running.
|
||||
pub fn get(self: &Arc<Self>) -> Vec<Device> {
|
||||
let mut s = self.state();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) {
|
||||
s.refreshing = true;
|
||||
let me = self.clone();
|
||||
// the scan runs without the state lock; `get` only ever waits for a list clone
|
||||
let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || {
|
||||
let list = (me.scanner)();
|
||||
me.store(list);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
s.refreshing = false;
|
||||
}
|
||||
}
|
||||
s.list.clone()
|
||||
}
|
||||
|
||||
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
|
||||
pub fn scan_now(&self) -> Vec<Device> {
|
||||
let list = (self.scanner)();
|
||||
self.store(list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
fn store(&self, list: Vec<Device>) {
|
||||
let changed = {
|
||||
let mut s = self.state();
|
||||
s.refreshing = false;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.scanned = Some(std::time::Instant::now());
|
||||
}
|
||||
let changed = s.list != list;
|
||||
s.list = list;
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(f) = f {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A background scan is running.
|
||||
pub fn refreshing(&self) -> bool {
|
||||
self.state().refreshing
|
||||
}
|
||||
}
|
||||
|
||||
fn global() -> &'static Arc<DeviceWatch> {
|
||||
static WATCH: std::sync::OnceLock<Arc<DeviceWatch>> = std::sync::OnceLock::new();
|
||||
WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE))
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan,
|
||||
/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
global().get()
|
||||
}
|
||||
|
||||
/// Scan the mounted volumes now (blocking), for explicit requests.
|
||||
pub fn devices_now() -> Vec<Device> {
|
||||
global().scan_now()
|
||||
}
|
||||
|
||||
/// Run `f` whenever a scan changes the device list (the UI requests a repaint).
|
||||
pub fn on_change(f: impl Fn() + Send + Sync + 'static) {
|
||||
global().on_change(f);
|
||||
}
|
||||
|
||||
fn scan() -> Vec<Device> {
|
||||
@@ -73,10 +180,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
roots.extend(extra);
|
||||
let mut out: Vec<Device> = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(rd) = std::fs::read_dir(&root) else { continue };
|
||||
let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dcim) = dcim_in(&root) else { continue };
|
||||
let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string());
|
||||
if !out.iter().any(|d| d.root == root.to_string_lossy()) {
|
||||
out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() });
|
||||
@@ -85,9 +189,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
out
|
||||
}
|
||||
|
||||
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
|
||||
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
|
||||
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
|
||||
return Some(p);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn volumes_with_a_dcim_folder_are_devices() {
|
||||
@@ -96,10 +213,76 @@ mod tests {
|
||||
std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap();
|
||||
std::fs::create_dir_all(base.join("Backup/Photos")).unwrap();
|
||||
std::fs::create_dir_all(base.join("SD/dcim")).unwrap();
|
||||
std::fs::create_dir_all(base.join("X/Dcim")).unwrap();
|
||||
let d = devices_in(std::slice::from_ref(&base), Vec::new());
|
||||
let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}");
|
||||
assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim"));
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}");
|
||||
// the DCIM folder itself, in a spelling that opens on that volume
|
||||
for x in &d {
|
||||
let p = std::path::Path::new(&x.path);
|
||||
assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
fn card(name: &str) -> Device {
|
||||
Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
|
||||
}
|
||||
|
||||
/// Wait (bounded) for `f`.
|
||||
fn until(f: impl Fn() -> bool) -> bool {
|
||||
let t = std::time::Instant::now();
|
||||
while t.elapsed() < Duration::from_secs(20) {
|
||||
if f() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_scans_never_block_and_hot_plug_still_arrives() {
|
||||
// a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
|
||||
const SLOW: Duration = Duration::from_millis(1500);
|
||||
let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
|
||||
let runs = Arc::new(AtomicUsize::new(0));
|
||||
let (m, r) = (mounted.clone(), runs.clone());
|
||||
let watch = DeviceWatch::new(
|
||||
move || {
|
||||
r.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(SLOW);
|
||||
m.lock().unwrap().clone()
|
||||
},
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
let changes = Arc::new(AtomicUsize::new(0));
|
||||
let c = changes.clone();
|
||||
watch.on_change(move || {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
// cold: served at once (empty) while one scan runs in the background
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..50 {
|
||||
assert!(watch.get().is_empty());
|
||||
}
|
||||
assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
|
||||
assert!(watch.refreshing());
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
|
||||
// a card is plugged in: the next stale request starts a rescan, still without waiting
|
||||
mounted.lock().unwrap().push(card("EOS_DIGITAL"));
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
let t = std::time::Instant::now();
|
||||
assert!(watch.get().is_empty(), "the old list is served while rescanning");
|
||||
assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
|
||||
assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
|
||||
assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
|
||||
// explicit requests scan synchronously
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
mounted.lock().unwrap().clear();
|
||||
assert!(watch.scan_now().is_empty());
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user