mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-18 05:07:09 -04:00
Compare commits
203
Commits
No files matched your search
@@ -1,16 +1,23 @@
|
||||
# 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.
|
||||
# FreeBSD build and package check in a QEMU VM.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -22,34 +29,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font build input, so the Japanese font tests run (they skip without it).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
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
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
@@ -1,7 +1,5 @@
|
||||
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
|
||||
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
|
||||
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
|
||||
# searches (a few minutes).
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -21,64 +19,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- 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
|
||||
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
|
||||
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
|
||||
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
|
||||
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
|
||||
done
|
||||
# The completion page the Publish events point at must actually exist: a Publish alone
|
||||
# does not pull the dialog fragment into the MSI, and without the DialogRef the
|
||||
# installer finishes silently (issues #790, #892).
|
||||
grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \
|
||||
|| { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
|
||||
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 - <<'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
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
|
||||
windows-install-folder:
|
||||
runs-on: windows-latest
|
||||
|
||||
+25
-479
@@ -1,13 +1,5 @@
|
||||
# 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.
|
||||
#
|
||||
# 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, Linux riscv64, 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.
|
||||
# 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.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -21,479 +13,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
LIGHTCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
timeout-minutes: 10
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# 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 }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
timeout-minutes: 90
|
||||
name: macOS universal
|
||||
needs: version
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
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 }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
timeout-minutes: 90
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- 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 }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
timeout-minutes: 90
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# 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 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # 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 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"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
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: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications'
|
||||
ldd target/release/lightcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'"
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
timeout-minutes: 90
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
timeout-minutes: 90
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the tarball
|
||||
run: |
|
||||
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # 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 # v1.5.9
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
timeout-minutes: 90
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
timeout-minutes: 10
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="LightCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-title: LightCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -1,22 +1,27 @@
|
||||
# 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.)
|
||||
# Windows ARM64 build and smoke test.
|
||||
#
|
||||
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
|
||||
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
|
||||
# on GitHub Actions' native windows-11-arm runner.
|
||||
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
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,70 +31,10 @@ concurrency:
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-dir: LightCraft
|
||||
@@ -125,11 +125,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
|
||||
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
|
||||
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
|
||||
|
||||
## 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`). `flake.nix` +
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
|
||||
(dev-dependency only). `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; CI only
|
||||
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
|
||||
|
||||
@@ -133,11 +133,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
|
||||
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
|
||||
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
|
||||
|
||||
## 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`). `flake.nix` +
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
|
||||
(dev-dependency only). `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; CI only
|
||||
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
|
||||
Generated
+52
-32
@@ -1072,6 +1072,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -3073,7 +3078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3081,6 +3086,7 @@ dependencies = [
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
@@ -3097,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -3114,7 +3120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -3130,7 +3136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
@@ -3144,6 +3150,7 @@ dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-testkit",
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
"proptest",
|
||||
@@ -3159,7 +3166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3168,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3185,7 +3192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3200,7 +3207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3212,9 +3219,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"craft-docpath",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
@@ -3240,6 +3248,7 @@ dependencies = [
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-segment",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-tiff",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3253,8 +3262,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
"proptest",
|
||||
@@ -3267,7 +3277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3278,7 +3288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
@@ -3286,7 +3296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3294,17 +3304,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
@@ -3312,7 +3324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
@@ -3320,14 +3332,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-inpaint"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-lensdb"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
@@ -3336,7 +3348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3347,7 +3359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3363,7 +3375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3375,7 +3387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3393,7 +3405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3405,7 +3417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3417,14 +3429,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-tiff",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
@@ -3439,7 +3453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3450,11 +3464,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3463,7 +3478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-slideshow"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"serde",
|
||||
@@ -3472,11 +3487,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-testkit"
|
||||
version = "0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3485,7 +3504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3504,6 +3523,7 @@ dependencies = [
|
||||
"lightcraft-preview",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3513,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -6983,7 +7003,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+4
-2
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
@@ -40,6 +41,7 @@ lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
lightcraft-geocode = { path = "crates/geocode" }
|
||||
lightcraft-slideshow = { path = "crates/slideshow" }
|
||||
lightcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -53,7 +55,7 @@ bytemuck = { version = "1", features = ["derive"] }
|
||||
crc32fast = "1"
|
||||
siphasher = "1"
|
||||
libc = "0.2" # dev-only: process CPU time in benches
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds
|
||||
|
||||
# formats
|
||||
quick-xml = "0.38"
|
||||
|
||||
@@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
|
||||
This is a contact-sheet workflow,
|
||||
not a printer-driver or custom picture-package layout editor.
|
||||
- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image,
|
||||
Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells,
|
||||
stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper
|
||||
sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi,
|
||||
and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command
|
||||
(`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`,
|
||||
`print.template.save`…), so the CLI and MCP drive it too:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
|
||||
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
|
||||
```
|
||||
|
||||
Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
|
||||
@@ -311,7 +325,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
@@ -395,6 +409,13 @@ information combined with LTO and one codegen unit on low-memory builders. A PKG
|
||||
its own profile settings can override inherited variables; adjust the packaging recipe locally or
|
||||
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
|
||||
|
||||
Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when
|
||||
those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip
|
||||
with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU
|
||||
adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and
|
||||
`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the
|
||||
GPU tests on every backend that has a hardware adapter. Nothing is uploaded.
|
||||
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
@@ -405,7 +426,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština) or **Settings → General →
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
|
||||
+1
-1
@@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
|
||||
+14
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
|
||||
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
|
||||
@@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---:|---|
|
||||
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (weighted by use) | **~77%** | estimated |
|
||||
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~51%** (46–56%) | estimated |
|
||||
@@ -45,8 +45,8 @@ Hours are calibrated on this repo's PR history; see
|
||||
| Dimension | Ready | Breadth | Remaining | Doc |
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~60% | 82% of 1,446 Adobe models decode, 36% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
@@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
| Area | Ready | Breadth | Remaining |
|
||||
|---|---:|---:|---:|
|
||||
| Import and library | 80% | 90% | 25–45 h |
|
||||
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
|
||||
| RAW decoding and camera coverage | 77% | 82% | 120–220 h |
|
||||
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
|
||||
| Global develop adjustments | 70% | 93% | 30–60 h |
|
||||
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
|
||||
@@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
|
||||
| Korean | ko | 0% | none (no Hangul face) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
|
||||
2. CR3 for every Canon body (25–45 h)
|
||||
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
|
||||
4. Full-resolution zoom (8–15 h)
|
||||
4. ~~Full-resolution zoom~~ (done 2026-10-12)
|
||||
5. AI masks: Subject / Sky / People (40–70 h + model decision)
|
||||
6. Compressed ORF and HE NEF (28–55 h)
|
||||
7. Lens profile database of our own (40–80 h)
|
||||
@@ -114,6 +114,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
|
||||
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
|
||||
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
|
||||
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
|
||||
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
|
||||
photographers on a verified body; headline numbers unchanged.
|
||||
@@ -180,6 +183,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
|
||||
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
|
||||
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
|
||||
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
|
||||
@@ -187,7 +191,10 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
|
||||
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
|
||||
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
|
||||
|
||||
@@ -828,6 +828,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
// as the desktop app opens it: an empty library on the grid, where Import is offered
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
|
||||
@@ -47,6 +47,48 @@ fn contact_sheet_exports_pdf_and_protects_originals() {
|
||||
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_commands_chain_into_a_picture_package_pdf() {
|
||||
let input = tmp("print-input.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("print.pdf");
|
||||
let (ok, lines, err) = run_cli(
|
||||
&[
|
||||
"--import",
|
||||
input.to_str().unwrap(),
|
||||
"print.set",
|
||||
r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#,
|
||||
r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#,
|
||||
"job={\"ppi\":72}",
|
||||
"print.cell.add",
|
||||
"width=1.5",
|
||||
"height=1",
|
||||
"print.cell.add",
|
||||
"width=1.5",
|
||||
"height=1",
|
||||
"print.layout.autoLayout",
|
||||
"print.exportPdf",
|
||||
&format!("path={}", out.display()),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(ok, "{err}: {lines:?}");
|
||||
let export = lines.last().unwrap();
|
||||
assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1)));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
|
||||
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in");
|
||||
// a bad setting fails the run and writes nothing new
|
||||
let before = std::fs::read(&out).unwrap();
|
||||
let (ok, _, _) =
|
||||
run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None);
|
||||
assert!(!ok);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), before);
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
|
||||
@@ -48,3 +48,7 @@ muda = { workspace = true }
|
||||
objc2 = { workspace = true }
|
||||
objc2-app-kit = { workspace = true }
|
||||
objc2-foundation = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
@@ -978,6 +978,11 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
// an empty library opens where Import is offered (a grid, with the sidebar); before
|
||||
// the snapshot below, so this alone isn't written to the settings file
|
||||
if problem.is_none() && files.is_empty() {
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
}
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
|
||||
@@ -278,11 +278,11 @@ mod tests {
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
|
||||
eprintln!("no GPU device: skipped");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU device");
|
||||
return;
|
||||
};
|
||||
install_error_handler(&device);
|
||||
|
||||
@@ -15,6 +15,9 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
|
||||
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | 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 |
|
||||
@@ -29,6 +32,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (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; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
|
||||
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
|
||||
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
|
||||
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)]
|
||||
cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)]
|
||||
@@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
|
||||
if !(first..=last).contains(&t) {
|
||||
return None;
|
||||
}
|
||||
let tail = iso.trim().get(19..).unwrap_or("");
|
||||
let iso = iso.trim();
|
||||
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
|
||||
let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
|
||||
Some(format!("{}{tail}", civil(t)))
|
||||
}
|
||||
|
||||
@@ -222,6 +224,15 @@ mod tests {
|
||||
assert_eq!(normalize_iso("2026-02-30"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_the_zone_of_a_time_without_seconds() {
|
||||
assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
|
||||
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_by_day_month_year_keep_stacks_together() {
|
||||
let mut c = Catalog::new();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library
|
||||
//! don't scan a list each time: which stack a photo is in, whether a regular album holds it.
|
||||
//!
|
||||
//! They are never saved (the on-disk format is unchanged). Each is built on first use from what
|
||||
//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts
|
||||
//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to
|
||||
//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`,
|
||||
//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the
|
||||
//! rollback of a failed batch included, as those are ops too.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::{AlbumId, Catalog, PhotoId, StackId};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Indexes {
|
||||
/// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks
|
||||
/// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts.
|
||||
stacks: OnceLock<Option<HashMap<PhotoId, StackId>>>,
|
||||
/// Album → its photos (regular albums; a smart album's list is empty).
|
||||
albums: OnceLock<HashMap<AlbumId, HashSet<PhotoId>>>,
|
||||
}
|
||||
|
||||
/// A copy builds its own when asked (copies are made to be saved, not queried).
|
||||
impl Clone for Indexes {
|
||||
fn clone(&self) -> Self {
|
||||
Indexes::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was
|
||||
/// built.
|
||||
impl PartialEq for Indexes {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Indexes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len));
|
||||
f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indexes {
|
||||
/// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone).
|
||||
pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) {
|
||||
let Some(Some(m)) = self.stacks.get_mut() else { return };
|
||||
for p in old {
|
||||
if m.get(p) == Some(&id) {
|
||||
m.remove(p);
|
||||
}
|
||||
}
|
||||
for p in new {
|
||||
m.insert(*p, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Album `id` holds `photos` now (`None`: it is gone).
|
||||
pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) {
|
||||
let Some(m) = self.albums.get_mut() else { return };
|
||||
match photos {
|
||||
Some(photos) => {
|
||||
m.insert(id, photos.iter().copied().collect());
|
||||
}
|
||||
None => {
|
||||
m.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The stack photo `photo` is in, by the index (built on first use).
|
||||
pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option<StackId> {
|
||||
let m = self.index.stacks.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for s in self.stacks.values() {
|
||||
for p in &s.photos {
|
||||
if m.insert(*p, s.id).is_some_and(|other| other != s.id) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
});
|
||||
match m {
|
||||
Some(m) => m.get(&photo).copied(),
|
||||
// in id order, the first stack listing the photo has it
|
||||
None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether album `album`'s own list holds `photo`, by the index (built on first use).
|
||||
pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool {
|
||||
let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect());
|
||||
m.get(&album).is_some_and(|s| s.contains(&photo))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
pub mod dates;
|
||||
pub mod folders;
|
||||
mod index;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
@@ -289,6 +290,9 @@ pub struct Catalog {
|
||||
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
|
||||
#[serde(skip)]
|
||||
changes: Changes,
|
||||
/// Lookups derived from the above (never saved), see [`index`].
|
||||
#[serde(skip)]
|
||||
index: index::Indexes,
|
||||
}
|
||||
|
||||
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
|
||||
@@ -435,13 +439,18 @@ impl Catalog {
|
||||
/// no smart album).
|
||||
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
|
||||
match self.albums.get(&id) {
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
|
||||
Some(a) => a.photos.contains(&p.id),
|
||||
Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p),
|
||||
Some(a) => self.album_lists(a.id, p.id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`.
|
||||
fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool {
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
!p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared))
|
||||
}
|
||||
|
||||
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
|
||||
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
|
||||
/// search, they don't repeat it.
|
||||
@@ -489,7 +498,10 @@ impl Catalog {
|
||||
/// The photos of an album: the stored list, or a smart album's current matches (id order).
|
||||
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(),
|
||||
Some(Album { smart: Some(rules), .. }) => {
|
||||
let prepared = rules.prepare();
|
||||
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect()
|
||||
}
|
||||
Some(a) => a.photos.clone(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
@@ -499,7 +511,10 @@ impl Catalog {
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
// 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(Album { smart: Some(rules), .. }) => {
|
||||
let prepared = rules.prepare();
|
||||
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count()
|
||||
}
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
@@ -750,6 +765,7 @@ impl Catalog {
|
||||
}
|
||||
let id = album.id;
|
||||
self.next_album = self.next_album.max(id.0.saturating_add(1));
|
||||
self.index.album_changed(id, Some(&album.photos));
|
||||
self.albums.insert(id, album);
|
||||
Op::RemoveAlbum { id }
|
||||
}
|
||||
@@ -758,6 +774,7 @@ impl Catalog {
|
||||
return Err(CatalogError::Invalid("folder is not empty".into()));
|
||||
}
|
||||
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
|
||||
self.index.album_changed(id, None);
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
Op::RenameAlbum { id, name } => {
|
||||
@@ -797,13 +814,16 @@ impl Catalog {
|
||||
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos } => {
|
||||
let a = self.album_mut(id)?;
|
||||
// the field, not `album_mut`: the index is updated while the album is borrowed
|
||||
let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?;
|
||||
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
|
||||
return Err(CatalogError::Invalid(
|
||||
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
|
||||
));
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
|
||||
let old = std::mem::replace(&mut a.photos, photos);
|
||||
self.index.album_changed(id, Some(&a.photos));
|
||||
Op::SetAlbumPhotos { id, photos: old }
|
||||
}
|
||||
Op::SetAlbumCover { id, cover } => {
|
||||
let a = self.album_mut(id)?;
|
||||
@@ -824,11 +844,13 @@ impl Catalog {
|
||||
self.validate_stack(stack.id, &stack.photos)?;
|
||||
let id = stack.id;
|
||||
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
|
||||
self.index.stack_changed(id, &[], &stack.photos);
|
||||
self.stacks.insert(id, stack);
|
||||
Op::RemoveStack { id }
|
||||
}
|
||||
Op::RemoveStack { id } => {
|
||||
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
|
||||
self.index.stack_changed(id, &s.photos, &[]);
|
||||
Op::AddStack { stack: s }
|
||||
}
|
||||
Op::SetStack { id, photos, collapsed } => {
|
||||
@@ -837,7 +859,9 @@ impl Catalog {
|
||||
}
|
||||
self.validate_stack(id, &photos)?;
|
||||
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
|
||||
let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed };
|
||||
let old_photos = std::mem::replace(&mut s.photos, photos);
|
||||
self.index.stack_changed(id, &old_photos, &s.photos);
|
||||
let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed };
|
||||
s.collapsed = collapsed;
|
||||
old
|
||||
}
|
||||
@@ -1075,6 +1099,10 @@ impl Catalog {
|
||||
self.next_album = self.next_album.max(album);
|
||||
self.next_stack = self.next_stack.max(stack);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
// stacks and albums changed outside `apply`: the lookups are built again when asked
|
||||
self.index = index::Indexes::default();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -1124,6 +1152,8 @@ mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_index;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
@@ -1132,4 +1162,6 @@ mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_repair;
|
||||
#[cfg(test)]
|
||||
mod tests_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -199,6 +199,11 @@ pub struct Meta {
|
||||
/// 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>,
|
||||
/// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony
|
||||
/// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never
|
||||
/// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<lightcraft_meta::FocusPoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
+43
-18
@@ -135,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
|
||||
mix64(mix64(seed) ^ id.0)
|
||||
}
|
||||
|
||||
/// What [`Filter::prepare`] works out once for a pass over many photos.
|
||||
pub(crate) struct Prepared {
|
||||
root: Option<String>,
|
||||
only: Option<std::collections::HashSet<PhotoId>>,
|
||||
}
|
||||
|
||||
fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
let t = tok.to_lowercase();
|
||||
if let Some((field, val)) = t.split_once(':') {
|
||||
@@ -167,11 +173,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
|
||||
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
|
||||
"name" | "file" => p.file_name.to_lowercase().contains(val),
|
||||
_ => false,
|
||||
// Not a known field: the colon is part of a plain word ("10:30", "Re:").
|
||||
_ => text_matches(p, &t),
|
||||
};
|
||||
}
|
||||
text_matches(p, &t)
|
||||
}
|
||||
|
||||
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
|
||||
/// format or a keyword of `p`.
|
||||
fn text_matches(p: &Photo, t: &str) -> bool {
|
||||
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
|
||||
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
|
||||
hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
|
||||
}
|
||||
|
||||
/// Whether `p` has a named face region called `name` (case-insensitive).
|
||||
@@ -257,7 +270,18 @@ impl Filter {
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
self.matches_in(p, cat, self.library_root().as_deref())
|
||||
self.matches_in(p, cat, &self.prepare_one())
|
||||
}
|
||||
|
||||
/// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed).
|
||||
pub(crate) fn prepare_one(&self) -> Prepared {
|
||||
Prepared { root: self.library_root(), only: None }
|
||||
}
|
||||
|
||||
/// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than
|
||||
/// once per photo: the library folder's identity and the `only` ids as a set.
|
||||
pub(crate) fn prepare(&self) -> Prepared {
|
||||
Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) }
|
||||
}
|
||||
|
||||
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
|
||||
@@ -267,9 +291,9 @@ impl Filter {
|
||||
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
|
||||
}
|
||||
|
||||
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
|
||||
/// over a big library does that once, not once per photo.
|
||||
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
|
||||
/// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a
|
||||
/// big library does that once, not once per photo.
|
||||
pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
@@ -308,7 +332,7 @@ impl Filter {
|
||||
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
|
||||
return false;
|
||||
}
|
||||
if !self.only.is_empty() && !self.only.contains(&p.id) {
|
||||
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
@@ -348,7 +372,7 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = root
|
||||
if let Some(root) = prepared.root.as_deref()
|
||||
&& !photo_in_root(p, root)
|
||||
{
|
||||
return false;
|
||||
@@ -398,12 +422,13 @@ impl Catalog {
|
||||
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
|
||||
/// the album, the folder; not deleted).
|
||||
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
|
||||
let root = filter.library_root();
|
||||
let within_root = within.library_root();
|
||||
let prepared = filter.prepare();
|
||||
// `within` only decides for the few kept photos: worked out per photo, not hashed
|
||||
let within_prepared = within.prepare_one();
|
||||
let mut v: Vec<&Photo> = self
|
||||
.photos()
|
||||
.map(|p| p.as_ref())
|
||||
.filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref())))
|
||||
.filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared)))
|
||||
.collect();
|
||||
v.sort_by(|a, b| sort.order(a, b));
|
||||
v.into_iter().map(|p| p.id).collect()
|
||||
@@ -417,11 +442,11 @@ impl Catalog {
|
||||
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
|
||||
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
|
||||
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
|
||||
let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root));
|
||||
let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one));
|
||||
for id in touched {
|
||||
let Some(p) = self.photo(*id) else { continue };
|
||||
let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref()));
|
||||
if filter.matches_in(p, self, root.as_deref()) || kept {
|
||||
let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp));
|
||||
if filter.matches_in(p, self, &prepared) || kept {
|
||||
// (the list is in order without the touched photos, and stays so as each is put back)
|
||||
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
|
||||
ids.insert(at, *id);
|
||||
@@ -432,8 +457,8 @@ impl Catalog {
|
||||
|
||||
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
|
||||
pub fn count(&self, filter: &Filter) -> usize {
|
||||
let root = filter.library_root();
|
||||
self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count()
|
||||
let prepared = filter.prepare();
|
||||
self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count()
|
||||
}
|
||||
|
||||
/// Year → month → day counts for the "By Date" section (newest first).
|
||||
@@ -489,8 +514,8 @@ impl Catalog {
|
||||
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();
|
||||
let root = filter.library_root();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
|
||||
let prepared = filter.prepare();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) {
|
||||
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 };
|
||||
|
||||
@@ -598,6 +598,35 @@ pub fn now() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flag as rules name it (its variant, lower case), without building a string per photo.
|
||||
fn flag_name(f: crate::Flag) -> &'static str {
|
||||
match f {
|
||||
crate::Flag::None => "none",
|
||||
crate::Flag::Pick => "pick",
|
||||
crate::Flag::Reject => "reject",
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour label's colour as rules name it (lower case).
|
||||
fn label_colour(l: crate::ColorLabel) -> &'static str {
|
||||
match l {
|
||||
crate::ColorLabel::Red => "red",
|
||||
crate::ColorLabel::Yellow => "yellow",
|
||||
crate::ColorLabel::Green => "green",
|
||||
crate::ColorLabel::Blue => "blue",
|
||||
crate::ColorLabel::Purple => "purple",
|
||||
}
|
||||
}
|
||||
|
||||
/// A media kind as rules name it (lower case).
|
||||
fn kind_name(k: crate::MediaKind) -> &'static str {
|
||||
match k {
|
||||
crate::MediaKind::Image => "image",
|
||||
crate::MediaKind::Raw => "raw",
|
||||
crate::MediaKind::Video => "video",
|
||||
}
|
||||
}
|
||||
|
||||
fn lower(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.trim().to_lowercase(),
|
||||
@@ -778,16 +807,16 @@ impl Rule {
|
||||
match field {
|
||||
"rating" => num_op(op, Some(p.rating as f64), value),
|
||||
"flag" => {
|
||||
let f = format!("{:?}", p.flag).to_lowercase();
|
||||
let f = flag_name(p.flag);
|
||||
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
|
||||
}
|
||||
"label" => {
|
||||
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
|
||||
let l = p.label.map_or("none", label_colour);
|
||||
// custom label names count too
|
||||
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
|
||||
let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want));
|
||||
(l == want || named) == (op == "is")
|
||||
}
|
||||
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
|
||||
"kind" => (kind_name(p.kind) == want) == (op == "is"),
|
||||
"edited" => yes(p.is_edited()),
|
||||
"hasGps" => yes(m.gps.is_some()),
|
||||
"virtualCopy" => yes(p.copy_of.is_some()),
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Catalog {
|
||||
}
|
||||
/// The stack a photo belongs to.
|
||||
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
|
||||
self.stacks.values().find(|s| s.photos.contains(&photo))
|
||||
self.stack_id_of(photo).and_then(|id| self.stacks.get(&id))
|
||||
}
|
||||
/// Photo → (stack, position in the stack), for every stacked photo.
|
||||
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
|
||||
@@ -41,7 +41,7 @@ impl Catalog {
|
||||
return Err(CatalogError::NoPhoto(*p));
|
||||
}
|
||||
}
|
||||
if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) {
|
||||
if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) {
|
||||
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -294,7 +294,8 @@ pub fn iso_seconds(s: &str) -> Option<i64> {
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
|
||||
let (hh, mm, ss) =
|
||||
if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
|
||||
// days from civil (proleptic Gregorian)
|
||||
let y2 = if m <= 2 { y - 1 } else { y };
|
||||
let era = y2.div_euclid(400);
|
||||
|
||||
@@ -123,6 +123,27 @@ fn filter_search_sort() {
|
||||
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
|
||||
}
|
||||
|
||||
/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
|
||||
/// text, not a fielded token that matches nothing.
|
||||
#[test]
|
||||
fn free_text_with_a_colon_searches_the_text_fields() {
|
||||
let mut c = Catalog::new();
|
||||
let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
|
||||
let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
|
||||
let mut m = c.photo(a).unwrap().meta.clone();
|
||||
m.caption = "Meeting at 10:30 with Re: notes".into();
|
||||
m.iso = Some(1600);
|
||||
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
|
||||
let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
|
||||
assert_eq!(q("meeting"), vec![a]);
|
||||
assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
|
||||
assert_eq!(q("re:"), vec![a]);
|
||||
assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
|
||||
assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
|
||||
assert_eq!(q("iso:>2000"), Vec::<PhotoId>::new());
|
||||
assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing");
|
||||
}
|
||||
|
||||
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
|
||||
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
|
||||
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
|
||||
@@ -425,6 +446,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a
|
||||
/// catalog from before it existed reads with none, and writes the same), kept exactly when known.
|
||||
#[test]
|
||||
fn focus_point_is_not_serialized_when_unknown_and_round_trips() {
|
||||
let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() };
|
||||
let v = serde_json::to_value(&m).unwrap();
|
||||
assert!(v.get("focus").is_none(), "{v}");
|
||||
let back: Meta = serde_json::from_value(v).unwrap();
|
||||
assert!(back.focus.is_none());
|
||||
let mut with = m.clone();
|
||||
with.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: Some("Wide".into()),
|
||||
tracking: None,
|
||||
});
|
||||
let json = serde_json::to_string(&with).unwrap();
|
||||
assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}");
|
||||
let back: Meta = serde_json::from_str(&json).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,0 +1,211 @@
|
||||
//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op,
|
||||
//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same
|
||||
//! results as before.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
const PHOTOS: u64 = 8;
|
||||
|
||||
fn library() -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for i in 0..PHOTOS {
|
||||
let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// What the indexes must say, worked out by scanning (as the catalog did before it had them).
|
||||
fn scanned(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
|
||||
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
|
||||
let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect();
|
||||
let albums = (1..=4)
|
||||
.map(|a| {
|
||||
photos
|
||||
.iter()
|
||||
.map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(stacks, albums)
|
||||
}
|
||||
|
||||
/// What the indexes say.
|
||||
fn indexed(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
|
||||
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
|
||||
let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect();
|
||||
let albums = (1..=4)
|
||||
.map(|a| {
|
||||
photos
|
||||
.iter()
|
||||
.map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(stacks, albums)
|
||||
}
|
||||
|
||||
/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail).
|
||||
fn ids(bits: u16) -> Vec<PhotoId> {
|
||||
(0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect()
|
||||
}
|
||||
|
||||
fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op {
|
||||
match kind % 8 {
|
||||
0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } },
|
||||
1 => Op::RemoveStack { id: StackId(n % 4 + 1) },
|
||||
2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed },
|
||||
3 => {
|
||||
let mut a = Album::new(AlbumId(n % 4 + 1), "A");
|
||||
a.photos = ids(bits);
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) },
|
||||
5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
|
||||
// a batch whose last op fails half the time: what it did is rolled back
|
||||
6 => Op::Batch {
|
||||
ops: vec![
|
||||
Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
|
||||
Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed },
|
||||
Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } },
|
||||
],
|
||||
},
|
||||
// as the engine deletes a photo: out of its albums and stacks too, in one batch
|
||||
_ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
#[test]
|
||||
fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::<bool>()), 1..40)) {
|
||||
let mut c = library();
|
||||
let start = c.to_snapshot();
|
||||
// built before the ops, so every op has to keep them up to date
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
let mut log = String::new();
|
||||
let mut inverses = Vec::new();
|
||||
for (kind, n, bits, collapsed) in steps {
|
||||
let op = op(&c, kind, n, bits, collapsed);
|
||||
if let Ok(inv) = c.apply(op.clone()) {
|
||||
log.push_str(&Catalog::op_to_log_line(&op));
|
||||
inverses.push(inv);
|
||||
}
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
}
|
||||
// a snapshot, a replayed journal and a clone build their own
|
||||
let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
|
||||
prop_assert_eq!(indexed(&snap), scanned(&c));
|
||||
let mut replayed = Catalog::from_snapshot(&start).unwrap();
|
||||
prop_assert_eq!(indexed(&replayed), scanned(&replayed));
|
||||
replayed.replay(&log).unwrap();
|
||||
prop_assert_eq!(indexed(&replayed), scanned(&c));
|
||||
prop_assert_eq!(indexed(&c.clone()), scanned(&c));
|
||||
prop_assert!(c.clone() == c, "the indexes don't take part in equality");
|
||||
// undone step by step
|
||||
for inv in inverses.into_iter().rev() {
|
||||
c.apply(inv).unwrap();
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
}
|
||||
prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_in_another_stack_is_refused_by_the_index() {
|
||||
let mut c = library();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
|
||||
let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err();
|
||||
assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into()));
|
||||
// its own stack may keep it
|
||||
c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap();
|
||||
assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1)));
|
||||
}
|
||||
|
||||
/// The sort keys are worked out once per photo now; the order is the one the comparisons gave.
|
||||
#[test]
|
||||
fn sorted_queries_keep_their_order() {
|
||||
let mut c = Catalog::new();
|
||||
let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"];
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3));
|
||||
p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4));
|
||||
p.rating = (i % 3) as u8;
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let all = Filter::default();
|
||||
for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random]
|
||||
{
|
||||
for ascending in [true, false] {
|
||||
let sort = Sort { key, ascending, seed: 7, ..Default::default() };
|
||||
// the comparison the query sorts by
|
||||
let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect();
|
||||
want.sort_by(|a, b| sort.order(a, b));
|
||||
let want: Vec<PhotoId> = want.iter().map(|p| p.id).collect();
|
||||
assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}");
|
||||
}
|
||||
}
|
||||
// `only` as a set gives the photos it names
|
||||
let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() };
|
||||
assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]);
|
||||
}
|
||||
|
||||
/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave).
|
||||
#[test]
|
||||
fn rule_names_are_the_variants_in_lower_case() {
|
||||
let mut c = Catalog::new();
|
||||
let mut n = 0;
|
||||
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
|
||||
for label in
|
||||
[None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)]
|
||||
{
|
||||
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
|
||||
n += 1;
|
||||
let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
(p.flag, p.label, p.kind) = (flag, label, kind);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap();
|
||||
let count = |field: &str, value: String| {
|
||||
let rules: RuleSet =
|
||||
serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap();
|
||||
c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len()
|
||||
};
|
||||
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
|
||||
assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}");
|
||||
}
|
||||
assert_eq!(count("flag", "picked".into()), 18);
|
||||
for label in ColorLabel::ALL {
|
||||
assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}");
|
||||
}
|
||||
assert_eq!(count("label", "sky".into()), 9, "a custom name");
|
||||
assert_eq!(count("label", "none".into()), 9);
|
||||
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
|
||||
assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo
|
||||
/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before
|
||||
/// and after either stack goes.
|
||||
#[test]
|
||||
fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() {
|
||||
let mut c = library();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap();
|
||||
v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]);
|
||||
let mut c = Catalog::from_snapshot(&v.to_string()).unwrap();
|
||||
assert_eq!(indexed(&c), scanned(&c));
|
||||
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1)));
|
||||
// the second stack can't be changed to keep it, as before
|
||||
assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err());
|
||||
c.apply(Op::RemoveStack { id: StackId(1) }).unwrap();
|
||||
assert_eq!(indexed(&c), scanned(&c));
|
||||
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! What library queries cost at 100k photos (ignored:
|
||||
//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags.
|
||||
fn library(n: u64) -> (Catalog, AlbumId) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ops: Vec<Op> = (0..n)
|
||||
.map(|i| {
|
||||
let mut p = Photo::new(
|
||||
PhotoId(i + 1),
|
||||
Source::Demo { scene: (i % 20) as u32 },
|
||||
&format!("IMG_{:06}.jpg", mix64(i) % 1_000_000),
|
||||
"JPEG",
|
||||
6000,
|
||||
4000,
|
||||
"2026-01-01T00:00:00",
|
||||
);
|
||||
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
|
||||
p.rating = (i % 6) as u8;
|
||||
p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None };
|
||||
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
|
||||
Op::AddPhoto { photo: Box::new(p) }
|
||||
})
|
||||
.collect();
|
||||
ops.extend(
|
||||
(0..n / 10)
|
||||
.map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }),
|
||||
);
|
||||
let album = AlbumId(1);
|
||||
let mut a = Album::new(album, "Big");
|
||||
a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect();
|
||||
ops.push(Op::AddAlbum { album: a });
|
||||
let t = Instant::now();
|
||||
c.apply(Op::Batch { ops }).unwrap();
|
||||
eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3);
|
||||
(c, album)
|
||||
}
|
||||
|
||||
fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId {
|
||||
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
|
||||
a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() }));
|
||||
c.apply(Op::AddAlbum { album: a }).unwrap();
|
||||
AlbumId(id)
|
||||
}
|
||||
|
||||
/// Best of five runs (the machine may be busy with other work).
|
||||
fn time<T>(what: &str, f: impl Fn() -> T) -> T {
|
||||
let mut best = f64::MAX;
|
||||
let mut r = None;
|
||||
for _ in 0..5 {
|
||||
let t = Instant::now();
|
||||
r = Some(f());
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
eprintln!("{what}: {best:.1} ms");
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scale_100k() {
|
||||
let (mut c, album) = library(100_000);
|
||||
let all = Filter::default();
|
||||
time("query, capture date", || c.query(&all, &Sort::default()));
|
||||
time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() }));
|
||||
time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default()));
|
||||
time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default()));
|
||||
let only: Vec<PhotoId> = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect();
|
||||
time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default()));
|
||||
let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]}));
|
||||
let flagged = smart(
|
||||
&mut c,
|
||||
11,
|
||||
json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}),
|
||||
);
|
||||
let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]}));
|
||||
let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]}));
|
||||
let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]}));
|
||||
for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] {
|
||||
let n = time(&format!("smart album count, {what}"), || c.album_count(a));
|
||||
eprintln!(" = {n}");
|
||||
}
|
||||
time("albums_of", || c.albums_of(PhotoId(6)));
|
||||
time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default())));
|
||||
time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count());
|
||||
}
|
||||
@@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
|
||||
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
|
||||
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
|
||||
|
||||
Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
|
||||
become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
|
||||
preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
|
||||
|
||||
## Dependencies and licences
|
||||
|
||||
All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
|
||||
@@ -44,11 +44,23 @@ impl Buf {
|
||||
match self {
|
||||
Buf::U8(v) => v[i] as f32 / 255.0,
|
||||
Buf::U16(v) => v[i] as f32 / 65535.0,
|
||||
Buf::F32(v) => v[i],
|
||||
Buf::F32(v) => normalize_float(v[i]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip non-finite device samples before colour conversion, preserving finite extended range.
|
||||
#[inline]
|
||||
fn normalize_float(value: f32) -> f32 {
|
||||
if value == f32::INFINITY {
|
||||
1.0
|
||||
} else if value.is_finite() {
|
||||
value
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved device samples as produced by a format decoder.
|
||||
pub(crate) struct Raw {
|
||||
pub width: usize,
|
||||
@@ -218,10 +230,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
|
||||
par_rows(&mut img.data, w, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = (y * w + x) * ch;
|
||||
let s = |c: usize| {
|
||||
let val = v[i + c];
|
||||
if val.is_finite() { val } else { 0.0 }
|
||||
};
|
||||
let s = |c: usize| normalize_float(v[i + c]);
|
||||
*o = if gray {
|
||||
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
|
||||
[l, l, l]
|
||||
|
||||
@@ -32,6 +32,7 @@ mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
pub mod print;
|
||||
pub mod proof;
|
||||
mod psd;
|
||||
mod sniff;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the
|
||||
//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space
|
||||
//! of the chosen printer / paper profile.
|
||||
//!
|
||||
//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the
|
||||
//! page to the device's own values. When the printer manages colour instead, the page is sent in a
|
||||
//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it.
|
||||
//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]).
|
||||
//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is
|
||||
//! measured by a round trip of black through the profile, and the page's linear values are scaled
|
||||
//! so its black lands there instead of every shadow below it clipping to the same ink. Linear
|
||||
//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping
|
||||
//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black
|
||||
//! mapping and are left alone.
|
||||
//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or
|
||||
//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown
|
||||
//! on screen (the print is what it corrects).
|
||||
//!
|
||||
//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table
|
||||
//! indexed by the 16-bit sample; the conversion itself is moxcms's.
|
||||
//!
|
||||
//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point
|
||||
//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions};
|
||||
|
||||
use crate::display::MAX_PROFILE_BYTES;
|
||||
use crate::proof::ProofIntent;
|
||||
use crate::space::{NamedSpace, Trc};
|
||||
|
||||
/// Where a print's colours go.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PrintTarget {
|
||||
/// The printer manages colour: the page is sent in this RGB space, with its profile embedded.
|
||||
Managed(NamedSpace),
|
||||
/// A printer / paper ICC profile: the page is converted to the device's values.
|
||||
Profile(Arc<Vec<u8>>),
|
||||
}
|
||||
|
||||
/// How the page is rendered into the target.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PrintColor {
|
||||
pub intent: ProofIntent,
|
||||
/// Map the page's black to the device's darkest (relative colorimetric only).
|
||||
pub black_point_compensation: bool,
|
||||
/// −100..100; 0 leaves the page as rendered.
|
||||
pub brightness: f32,
|
||||
/// −100..100; 0 leaves the page as rendered.
|
||||
pub contrast: f32,
|
||||
}
|
||||
|
||||
impl Default for PrintColor {
|
||||
fn default() -> Self {
|
||||
PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size.
|
||||
const BATCH: usize = 1 << 16;
|
||||
|
||||
/// The darkest black point compensation will lift the page's black to. A profile whose round trip
|
||||
/// says more is broken or not a print profile; leave the page alone rather than wash it out.
|
||||
const MAX_BLACK: f32 = 0.5;
|
||||
|
||||
type Executor = Arc<dyn TransformExecutor<f32> + Send + Sync>;
|
||||
|
||||
/// A page conversion, built once per print job and applied to every page.
|
||||
pub struct PrintTransform {
|
||||
forward: Executor,
|
||||
layout: Layout,
|
||||
channels: usize,
|
||||
icc: Arc<Vec<u8>>,
|
||||
/// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation.
|
||||
pre: Vec<f32>,
|
||||
black: f32,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PrintTransform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PrintTransform")
|
||||
.field("channels", &self.channels)
|
||||
.field("black", &self.black)
|
||||
.field("description", &self.description)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
|
||||
}
|
||||
|
||||
fn rendering_intent(intent: ProofIntent) -> RenderingIntent {
|
||||
match intent {
|
||||
ProofIntent::Perceptual => RenderingIntent::Perceptual,
|
||||
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
|
||||
}
|
||||
}
|
||||
|
||||
/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0).
|
||||
fn amount(v: f32) -> f32 {
|
||||
if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a
|
||||
/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black,
|
||||
/// mid grey and white where they are and stays monotonic for |k| < 1.
|
||||
pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 {
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (b, c) = (amount(brightness), amount(contrast));
|
||||
let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e };
|
||||
let k = 0.6 * c;
|
||||
let tau = std::f32::consts::TAU;
|
||||
(e - k * (tau * e).sin() / tau).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
impl PrintTransform {
|
||||
/// Build the conversion for a target. Errors say what's wrong with the profile; nothing here
|
||||
/// panics on a hostile one.
|
||||
pub fn new(target: &PrintTarget, color: &PrintColor) -> Result<PrintTransform, String> {
|
||||
let icc = match target {
|
||||
PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)),
|
||||
PrintTarget::Profile(bytes) => Arc::clone(bytes),
|
||||
};
|
||||
if icc.len() > MAX_PROFILE_BYTES {
|
||||
return Err("the profile is too large".into());
|
||||
}
|
||||
let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?;
|
||||
let (layout, channels) = match dev.color_space {
|
||||
DataColorSpace::Rgb => (Layout::Rgb, 3),
|
||||
DataColorSpace::Cmyk => (Layout::Rgba, 4),
|
||||
DataColorSpace::Gray => (Layout::Gray, 1),
|
||||
_ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()),
|
||||
};
|
||||
let lin = crate::icc::linear_rec2020_profile();
|
||||
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
|
||||
let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok())
|
||||
.ok_or("can't convert to this profile")?;
|
||||
|
||||
let black = if color.black_point_compensation && color.intent == ProofIntent::Relative {
|
||||
let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok())
|
||||
.ok_or("can't convert from this profile")?;
|
||||
device_black(&forward, &back, channels)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let decode = Trc::Rec709.lut16();
|
||||
let (b, c) = (color.brightness, color.contrast);
|
||||
let adjust = amount(b) != 0.0 || amount(c) != 0.0;
|
||||
let pre = decode
|
||||
.iter()
|
||||
.map(|&lin| {
|
||||
let lin = if adjust {
|
||||
let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0));
|
||||
lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c))
|
||||
} else {
|
||||
lin.clamp(0.0, 1.0)
|
||||
};
|
||||
lin * (1.0 - black) + black
|
||||
})
|
||||
.collect();
|
||||
let description = crate::icc::parse(&icc).and_then(|i| i.description);
|
||||
Ok(PrintTransform { forward, layout, channels, icc, pre, black, description })
|
||||
}
|
||||
|
||||
/// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey).
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels
|
||||
}
|
||||
|
||||
/// The profile the converted samples are in: embed it with them.
|
||||
pub fn icc(&self) -> &Arc<Vec<u8>> {
|
||||
&self.icc
|
||||
}
|
||||
|
||||
/// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise,
|
||||
/// so writers need to know).
|
||||
pub fn is_cmyk(&self) -> bool {
|
||||
self.layout == Layout::Rgba
|
||||
}
|
||||
|
||||
/// The linear black black point compensation lifts the page's black to (0 when off).
|
||||
pub fn black(&self) -> f32 {
|
||||
self.black
|
||||
}
|
||||
|
||||
/// The profile's description, for the job's settings and error messages.
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
|
||||
/// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result
|
||||
/// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel).
|
||||
pub fn apply(&self, rgb: &[u16]) -> Result<Vec<u16>, String> {
|
||||
if !rgb.len().is_multiple_of(3) {
|
||||
return Err("the page isn't whole RGB pixels".into());
|
||||
}
|
||||
let pixels = rgb.len() / 3;
|
||||
let ch = self.channels;
|
||||
let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?;
|
||||
let mut out = vec![0u16; out_len];
|
||||
let run = |src: &[u16], dst: &mut [u16]| -> Option<()> {
|
||||
let lin: Vec<f32> = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect();
|
||||
let mut dev = vec![0f32; dst.len()];
|
||||
self.forward.transform(&lin, &mut dev).ok()?;
|
||||
for (d, v) in dst.iter_mut().zip(dev) {
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
*d = (v * 65535.0 + 0.5) as u16;
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
let src = rgb.chunks(BATCH * 3);
|
||||
let dst = out.chunks_mut(BATCH * ch);
|
||||
#[cfg(feature = "parallel")]
|
||||
let ok = {
|
||||
use rayon::prelude::*;
|
||||
let pairs: Vec<_> = src.zip(dst).collect();
|
||||
pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some())
|
||||
};
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some());
|
||||
if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative
|
||||
/// colorimetric both ways (for a device that reaches true black, 0).
|
||||
fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 {
|
||||
let mut device = vec![0f32; channels];
|
||||
let mut lin = [0f32; 3];
|
||||
let ok = guarded(|| {
|
||||
forward.transform(&[0.0; 3], &mut device).ok()?;
|
||||
back.transform(&device, &mut lin).ok()
|
||||
});
|
||||
if ok.is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
// BT.2020 luminance weights
|
||||
let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2];
|
||||
if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use moxcms::ToneReprCurve;
|
||||
|
||||
/// A page of one 16-bit pixel per linear grey level.
|
||||
fn greys(levels: &[f32]) -> Vec<u16> {
|
||||
levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect()
|
||||
}
|
||||
|
||||
fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec<u16> {
|
||||
PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap()
|
||||
}
|
||||
|
||||
/// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey.
|
||||
fn raised_black_profile() -> Arc<Vec<u8>> {
|
||||
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap();
|
||||
let (g, e) = (2.2f32, 0.03f32);
|
||||
let a = (1.0 - e).powf(1.0 / g);
|
||||
// ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below
|
||||
let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]);
|
||||
p.red_trc = Some(trc.clone());
|
||||
p.green_trc = Some(trc.clone());
|
||||
p.blue_trc = Some(trc);
|
||||
Arc::new(p.encode().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_srgb_keeps_black_grey_and_white() {
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap();
|
||||
assert_eq!(t.channels(), 3);
|
||||
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb));
|
||||
let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap();
|
||||
let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32;
|
||||
assert!(out[..3].iter().all(|&v| v < 64), "{out:?}");
|
||||
assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}");
|
||||
assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adobe_rgb_target_embeds_its_profile() {
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap();
|
||||
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb));
|
||||
// a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped
|
||||
let px = [0u16, 65535, 0];
|
||||
let out = t.apply(&px).unwrap();
|
||||
assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_lifts_the_mid_tones_and_holds_the_ends() {
|
||||
let target = PrintTarget::Managed(NamedSpace::Srgb);
|
||||
let levels = [0.0, 0.18, 1.0];
|
||||
let plain = convert(&target, PrintColor::default(), &levels);
|
||||
let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels);
|
||||
let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels);
|
||||
assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}");
|
||||
for v in [&plain, &bright, &dark] {
|
||||
assert!(v[0] < 64 && v[6] > 65400, "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() {
|
||||
let target = PrintTarget::Managed(NamedSpace::Srgb);
|
||||
let levels = [0.03, 0.6];
|
||||
let plain = convert(&target, PrintColor::default(), &levels);
|
||||
let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels);
|
||||
assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}");
|
||||
for c in [-100.0, 100.0] {
|
||||
for b in [-100.0, 100.0] {
|
||||
let ramp: Vec<f32> = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect();
|
||||
assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}");
|
||||
assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() {
|
||||
let target = PrintTarget::Profile(raised_black_profile());
|
||||
let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0];
|
||||
let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() };
|
||||
let clipped = convert(&target, relative, &shadows);
|
||||
let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows);
|
||||
let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::<Vec<_>>();
|
||||
let (clipped, kept) = (reds(&clipped), reds(&kept));
|
||||
// everything below the ink's black prints as (within rounding) the same device value without
|
||||
// compensation …
|
||||
assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}");
|
||||
// … and as distinct, rising values with it; white still prints as white
|
||||
assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}");
|
||||
assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}");
|
||||
let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap();
|
||||
assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_is_relative_colorimetric_only() {
|
||||
let target = PrintTarget::Profile(raised_black_profile());
|
||||
let levels = [0.0, 0.01, 0.2];
|
||||
let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels);
|
||||
let without =
|
||||
convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels);
|
||||
assert_eq!(with, without);
|
||||
let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap();
|
||||
assert_eq!(t.black(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
let color = PrintColor::default();
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err());
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err());
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err());
|
||||
let good = crate::icc::write_named(NamedSpace::DisplayP3);
|
||||
for i in (0..good.len()).step_by(7) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) {
|
||||
let _ = t.apply(&greys(&[0.0, 0.5, 1.0]));
|
||||
}
|
||||
}
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color })
|
||||
.unwrap();
|
||||
assert!(t.apply(&[1, 2]).is_err(), "half a pixel");
|
||||
assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18]));
|
||||
assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
fn corpus_images_decode() {
|
||||
let dir = corpus_root().join("images");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let (mut ok, mut unsupported) = (0, 0);
|
||||
@@ -57,7 +57,7 @@ fn corpus_images_decode() {
|
||||
fn corpus_pngsuite() {
|
||||
let dir = corpus_root().join("pngsuite");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let mut n = 0;
|
||||
@@ -83,7 +83,7 @@ fn corpus_pngsuite() {
|
||||
fn corpus_raw_is_routed() {
|
||||
let dir = corpus_root().join("raw");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
for p in files(&dir) {
|
||||
@@ -103,7 +103,7 @@ fn system_icc_profiles() {
|
||||
use lightcraft_codecs::icc::{IccColorModel, IccKind, parse};
|
||||
let dir = Path::new("/System/Library/ColorSync/Profiles");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: no system ICC profiles");
|
||||
lightcraft_testkit::skip!(System, "no system ICC profiles");
|
||||
return;
|
||||
}
|
||||
let expect = [
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
|
||||
|
||||
use lightcraft_codecs::{icc, *};
|
||||
|
||||
fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_tiff(
|
||||
&EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
|
||||
TiffCompression::None,
|
||||
&EncodeMeta { icc: profile, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
|
||||
let samples =
|
||||
[[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
|
||||
let bytes = tiff(samples.as_flattened(), 3, None);
|
||||
let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert!(decoded.float);
|
||||
assert_eq!(decoded.bit_depth, 32);
|
||||
assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
|
||||
let display = decoded.to_srgb8();
|
||||
assert_eq!(display.data[0], [255; 4]);
|
||||
for pixel in &display.data[1..4] {
|
||||
assert_eq!(*pixel, [0, 0, 0, 255]);
|
||||
}
|
||||
assert_eq!(display.data[6], [255, 188, 0, 255]);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
|
||||
// A box-filtered pair must average clipped white with its ordinary neighbor.
|
||||
let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
|
||||
let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
|
||||
assert_eq!(small.image.data, [[0.625; 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
|
||||
let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
|
||||
let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
|
||||
let profiles = [
|
||||
None,
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
|
||||
Some(icc::write_named(NamedSpace::Srgb)),
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
|
||||
Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
|
||||
Some(b"invalid ICC profile".to_vec()),
|
||||
];
|
||||
for channels in [1, 3] {
|
||||
let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
|
||||
let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
|
||||
for profile in &profiles {
|
||||
let profile = profile.as_deref();
|
||||
let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
|
||||
let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.grayscale, channels == 1);
|
||||
assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
|
||||
assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
|
||||
assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
|
||||
assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
|
||||
let samples = [
|
||||
[f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
|
||||
[f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
|
||||
[0.25, 0.5, 2.0, f32::NEG_INFINITY],
|
||||
[2.0, 3.0, 4.0, 0.5],
|
||||
];
|
||||
let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
|
||||
assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
|
||||
// Associated alpha takes the same path before unpremultiplication.
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
|
||||
let mut image = encoder.new_image::<colortype::RGBA32Float>(2, 1).unwrap();
|
||||
image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
|
||||
image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
|
||||
}
|
||||
let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
|
||||
assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
|
||||
}
|
||||
|
||||
fn lut_profile() -> Vec<u8> {
|
||||
use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
|
||||
let mut profile = ColorProfile::new_srgb();
|
||||
profile.cicp = None;
|
||||
profile.red_colorant = Xyzd::default();
|
||||
profile.green_colorant = Xyzd::default();
|
||||
profile.blue_colorant = Xyzd::default();
|
||||
profile.red_trc = None;
|
||||
profile.green_trc = None;
|
||||
profile.blue_trc = None;
|
||||
// A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
|
||||
profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
|
||||
num_input_channels: 3,
|
||||
num_output_channels: 3,
|
||||
grid_points: [0; 16],
|
||||
clut: None,
|
||||
a_curves: vec![],
|
||||
m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
|
||||
matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
|
||||
b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
|
||||
bias: Vector3d::default(),
|
||||
}));
|
||||
profile.encode().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
|
||||
let profile = lut_profile();
|
||||
let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
|
||||
let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
|
||||
let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
|
||||
let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
|
||||
assert_eq!(decoded.image, reference.image);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
|
||||
craft-docpath = { workspace = true }
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
@@ -52,6 +54,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and
|
||||
//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes
|
||||
//! nothing there, and probes no file (the library is made from the folder's own listing).
|
||||
//!
|
||||
//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>`
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with};
|
||||
|
||||
fn main() {
|
||||
let Some(folder) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: sync_scan_bench <folder>");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let t = Instant::now();
|
||||
let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None);
|
||||
println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64());
|
||||
for f in &files {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00");
|
||||
if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() {
|
||||
eprintln!("could not add {name}");
|
||||
}
|
||||
}
|
||||
for run in 0..2 {
|
||||
let input = match SyncInput::new(&mut s, &folder, true) {
|
||||
Ok(input) => input,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let t = Instant::now();
|
||||
let changes = scan_with(input, &SyncProgress::default());
|
||||
println!(
|
||||
"run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)",
|
||||
t.elapsed().as_secs_f64(),
|
||||
changes.new.len(),
|
||||
changes.missing.len(),
|
||||
changes.metadata.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,9 @@ pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
|
||||
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
|
||||
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
];
|
||||
@@ -195,10 +198,12 @@ impl Pool {
|
||||
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow
|
||||
// the warp the camera applied to its JPEG)
|
||||
let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
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 Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) 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;
|
||||
|
||||
@@ -224,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// 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();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"added": 0}));
|
||||
}
|
||||
@@ -410,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no photos to move: select some or give ids"));
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.convertToDng";
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
let mut converted = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let mut ids = s.targets(p);
|
||||
let mut ids = s.targets(p)?;
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no active photo"));
|
||||
@@ -240,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
m.aperture = m.aperture.or(src.aperture);
|
||||
m.iso = m.iso.or(src.iso);
|
||||
m.gps = m.gps.or(src.gps);
|
||||
// the camera's focus point: photos imported before it was read get it here
|
||||
if m.focus.is_none() {
|
||||
m.focus = src.focus.clone();
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
if m != before {
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
@@ -311,7 +315,7 @@ pub(crate) fn reload_ops(
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
@@ -340,7 +344,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.duplicate";
|
||||
let mut made = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
|
||||
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
|
||||
let src = Path::new(path);
|
||||
@@ -441,6 +445,27 @@ mod tests {
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// Reload from Disk gives a photo imported before focus points were read its camera focus point,
|
||||
/// and never replaces one it has.
|
||||
#[test]
|
||||
fn reload_fills_a_missing_focus_point() {
|
||||
let fp = |x: f64| lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: None,
|
||||
tracking: None,
|
||||
};
|
||||
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00");
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() };
|
||||
info.meta.focus = Some(fp(0.3));
|
||||
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
|
||||
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
|
||||
assert_eq!(meta.focus, Some(fp(0.3)));
|
||||
ph.meta.focus = Some(fp(0.1));
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept");
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
@@ -514,7 +539,7 @@ mod tests {
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
|
||||
@@ -16,11 +16,11 @@ const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
Some(d.unix_seconds())
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
|
||||
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}),
|
||||
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -304,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
None => s.targets(p)?,
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
@@ -811,7 +811,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
|
||||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect();
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -838,7 +838,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||||
clip = lightcraft_develop::extract_groups(&full, &only);
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -890,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let delta = f64_req(p, "delta", c)?;
|
||||
let label = format!("Quick Develop: {}", spec.label);
|
||||
let ops: Vec<Op> = s
|
||||
.targets(p)
|
||||
.targets(p)?
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
@@ -943,7 +943,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
|
||||
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect();
|
||||
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
|
||||
for id in ids {
|
||||
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
|
||||
@@ -978,7 +978,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
|
||||
let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?;
|
||||
let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;
|
||||
let ids = s.targets(p);
|
||||
// `id` names the preset here: the photos are `ids`, else the selection
|
||||
let ids = s.targets(&json!({"ids": p.get("ids")}))?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
|
||||
@@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_meta::{Region, RegionKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection};
|
||||
use super::{CommandSpec, always, bad, cmd, has_selection};
|
||||
use crate::{AiModel, EngineError, Result, Session};
|
||||
|
||||
const C: &str = "faces.detect";
|
||||
@@ -54,10 +54,12 @@ impl NoDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// The YuNet loaded last, and from where.
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
|
||||
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
|
||||
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
|
||||
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
let home = s
|
||||
.face_models_dir
|
||||
.as_deref()
|
||||
@@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete
|
||||
let d = Arc::new(
|
||||
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let net = d.clone();
|
||||
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
|
||||
}
|
||||
*loaded = Some((path, d.clone()));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GpuState {
|
||||
/// The background check is still running.
|
||||
pub checking: bool,
|
||||
/// The GPU it runs on; `None` when it runs on the CPU.
|
||||
pub device: Option<String>,
|
||||
/// Why it runs on the CPU.
|
||||
pub why: Option<String>,
|
||||
}
|
||||
|
||||
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
|
||||
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
|
||||
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
|
||||
}
|
||||
|
||||
fn set_gpu_state(what: &'static str, st: GpuState) {
|
||||
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match all.iter_mut().find(|(w, _)| *w == what) {
|
||||
Some((_, old)) => *old = st,
|
||||
None => all.push((what, st)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user chose the CPU for face models (Settings ▸ Faces).
|
||||
pub(crate) fn cpu_only(s: &Session) -> bool {
|
||||
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
|
||||
}
|
||||
|
||||
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
|
||||
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
|
||||
/// The check shows in the activity stack while it runs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn offer_gpu(
|
||||
s: &Session,
|
||||
what: &'static str,
|
||||
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
|
||||
) {
|
||||
if cpu_only(s) {
|
||||
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
|
||||
return;
|
||||
}
|
||||
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
|
||||
task.detail(what);
|
||||
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
|
||||
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
|
||||
let _task = task;
|
||||
let gpu = match lightcraft_gpu::faces::accelerator() {
|
||||
Ok(gpu) => gpu,
|
||||
Err(why) => {
|
||||
log::info!("faces: {what} runs on the CPU ({why})");
|
||||
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
|
||||
}
|
||||
};
|
||||
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
|
||||
match attach(gpu) {
|
||||
Ok(()) => {
|
||||
log::info!("faces: {what} runs on {device}");
|
||||
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
|
||||
}
|
||||
Err(why) => {
|
||||
log::info!("faces: {what} stays on the CPU: {why}");
|
||||
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("faces: could not check the GPU for {what}: {e}");
|
||||
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
|
||||
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.gpu";
|
||||
if let Some(cpu) = p.get("cpuOnly") {
|
||||
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
|
||||
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
|
||||
super::face_models::set_cpu_only(&dir, cpu)?;
|
||||
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
|
||||
if cpu {
|
||||
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
|
||||
if let Some(net) = net {
|
||||
net.detach();
|
||||
}
|
||||
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
|
||||
}
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(d) = detector {
|
||||
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
|
||||
}
|
||||
if let Some(e) = embedder {
|
||||
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(gpu_json(s))
|
||||
}
|
||||
|
||||
/// What `faces.gpu` and `faces.models.list` say about the GPU.
|
||||
pub(crate) fn gpu_json(s: &Session) -> Value {
|
||||
json!({
|
||||
"cpuOnly": cpu_only(s),
|
||||
"detection": gpu_state("detection"),
|
||||
"recognition": gpu_state("recognition"),
|
||||
})
|
||||
}
|
||||
|
||||
fn face_json(f: &Face) -> Value {
|
||||
json!({
|
||||
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
|
||||
@@ -132,7 +257,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let detector = detector(s).map_err(|e| e.into_error(C))?;
|
||||
let started = Instant::now();
|
||||
let (mut results, mut ops) = (Vec::new(), Vec::new());
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
),
|
||||
cmd!(
|
||||
query "faces.gpu",
|
||||
"Face Models on the GPU",
|
||||
[],
|
||||
None,
|
||||
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
|
||||
always,
|
||||
gpu
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
|
||||
pub(super) enabled: bool,
|
||||
/// The installed recogniser in use.
|
||||
pub(super) embedder: Option<String>,
|
||||
/// Keep every face model on the CPU, even when the GPU would be faster.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub(super) cpu_only: bool,
|
||||
}
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep the face models on the CPU (or let them use the GPU again).
|
||||
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
|
||||
let mut st = read_settings(dir);
|
||||
st.cpu_only = on;
|
||||
write_settings(dir, &st)
|
||||
}
|
||||
|
||||
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
|
||||
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
|
||||
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"enabled": settings.enabled,
|
||||
"embedder": settings.embedder,
|
||||
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
|
||||
"gpu": super::face_detect::gpu_json(s),
|
||||
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
|
||||
"runtime": cfg!(not(target_arch = "wasm32")),
|
||||
"models": models,
|
||||
|
||||
@@ -321,6 +321,8 @@ mod imp {
|
||||
return Ok(e.clone());
|
||||
}
|
||||
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
|
||||
let net = embedder.clone();
|
||||
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
|
||||
let dim = match installed.manifest.output {
|
||||
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
|
||||
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
|
||||
@@ -672,6 +674,9 @@ mod imp {
|
||||
if let Some(w) = &s.faces.worker {
|
||||
w.allow(target, pace);
|
||||
}
|
||||
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
|
||||
let (runs, share) = crate::faces_worker::gpu_pace(pace);
|
||||
lightcraft_gpu::faces::set_pace(runs, share);
|
||||
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
|
||||
let Some(id) = s.faces.queue.pop() else { break };
|
||||
let Some(job) = prepare(s, id, true) else { continue };
|
||||
@@ -841,7 +846,7 @@ mod imp {
|
||||
const C: &str = "faces.suggest";
|
||||
let embedder = current(s, C)?;
|
||||
collect(s);
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let model_threshold = embedder.manifest().thresholds.match_cosine;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32);
|
||||
let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the
|
||||
//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, cmd};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::read_points(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_points(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::pump(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn pump(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(serde_json::json!({"active": false}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.readFocusPoints",
|
||||
"Read Focus Points",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)",
|
||||
super::always,
|
||||
read_points
|
||||
),
|
||||
cmd!(
|
||||
query "focus.pump",
|
||||
"Read Focus Points in the Background",
|
||||
[],
|
||||
None,
|
||||
"{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at",
|
||||
super::always,
|
||||
pump
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
@@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
|s, p| {
|
||||
let mut current: Vec<String> = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
if let Some(ph) = s.catalog.photo(id) {
|
||||
current.extend(ph.meta.keywords.iter().cloned());
|
||||
}
|
||||
@@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
|
||||
@@ -182,7 +182,7 @@ fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize>
|
||||
|
||||
/// Apply one op per target as a single undo step.
|
||||
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
@@ -194,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
@@ -213,7 +213,7 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
|
||||
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
|
||||
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
|
||||
let flag = step(old);
|
||||
if flag != old {
|
||||
@@ -258,7 +258,7 @@ fn row_step(p: &Value) -> isize {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
@@ -277,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
@@ -883,7 +883,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let n = mark_deleted(s, &targets, "Delete")?;
|
||||
select_after_deletion(s, &before);
|
||||
Ok(json!({"changed": n}))
|
||||
@@ -912,7 +912,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let t = s.targets(p)?;
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
select_after_deletion(s, &before);
|
||||
@@ -929,7 +929,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let strs =
|
||||
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
|
||||
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
|
||||
None => None,
|
||||
@@ -1027,8 +1027,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let (photos, skipped) =
|
||||
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
|
||||
let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
@@ -1268,7 +1267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
@@ -1276,8 +1275,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
// looked up, not searched for: adding thousands to a big album stays linear
|
||||
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
if held.insert(t) {
|
||||
photos.push(t);
|
||||
}
|
||||
}
|
||||
@@ -1295,7 +1296,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
@@ -1308,11 +1309,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
|
||||
// looked up, not searched for: thousands toggled in a big album stay linear
|
||||
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
|
||||
let photos: Vec<PhotoId> = if all_in {
|
||||
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
|
||||
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
|
||||
} else {
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
|
||||
};
|
||||
let name = al.name.clone();
|
||||
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
|
||||
@@ -1454,7 +1458,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
|
||||
},
|
||||
};
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
|
||||
if al.is_smart() {
|
||||
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
||||
@@ -1463,6 +1467,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("album.removePhotos", "album folders don't hold photos"));
|
||||
}
|
||||
let name = al.name.clone();
|
||||
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
|
||||
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
|
||||
let removed = al.photos.len().saturating_sub(photos.len());
|
||||
if removed > 0 {
|
||||
@@ -1483,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
|
||||
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
|
||||
@@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
imported.push(json!({"id": id, "name": display, "group": group}));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
// presets that name these profiles use them now
|
||||
crate::profile_refs::refresh(s);
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
@@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
s.save_prefs()?;
|
||||
crate::profile_refs::refresh(s);
|
||||
Ok(json!({"deleted": id}))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::Result;
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
Ok((s.targets(p), template, start))
|
||||
Ok((s.targets(p)?, template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
@@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
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;
|
||||
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
|
||||
let c = "photo.setCaptureTime";
|
||||
let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(c, "no photos"));
|
||||
}
|
||||
|
||||
@@ -150,6 +150,55 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas
|
||||
Ok(json!({"activeMask": active}))
|
||||
}
|
||||
|
||||
/// Most pieces a tapered ⇧-click segment is cut into.
|
||||
const MAX_TAPER_STEPS: usize = 64;
|
||||
|
||||
/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment
|
||||
/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the
|
||||
/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their
|
||||
/// maximum, so the pieces join without building up.
|
||||
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
|
||||
let Some(&first) = to.points.first() else { return vec![to] };
|
||||
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
|
||||
// pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any)
|
||||
let pressured = !last.pressure.is_empty() || !to.pressure.is_empty();
|
||||
let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0));
|
||||
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
|
||||
let mut points = Vec::with_capacity(to.points.len() + 1);
|
||||
points.push(from);
|
||||
points.extend(to.points.iter().copied());
|
||||
let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() };
|
||||
return vec![BrushStroke { points, pressure, ..to }];
|
||||
}
|
||||
// about 5% of size, or 2 points of feather or flow, per piece
|
||||
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
|
||||
let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0]
|
||||
.into_iter()
|
||||
.filter(|v| v.is_finite())
|
||||
.fold(1.0f64, f64::max);
|
||||
let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS);
|
||||
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
|
||||
let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t));
|
||||
let mut out: Vec<BrushStroke> = (0..steps)
|
||||
.map(|i| {
|
||||
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
|
||||
let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32;
|
||||
BrushStroke {
|
||||
points: vec![at(t0), at(t1)],
|
||||
size: lerp(last.size, to.size, t1),
|
||||
feather: lerp(last.feather, to.feather, t1),
|
||||
flow: lerp(last.flow, to.flow, t1),
|
||||
pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() },
|
||||
..to.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if to.points.len() > 1 {
|
||||
out.push(to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
|
||||
}
|
||||
@@ -233,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Stroke",
|
||||
[],
|
||||
None,
|
||||
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full)}",
|
||||
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let pts: Vec<Point> = p
|
||||
@@ -255,10 +304,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
auto_mask: bool_or(p, "autoMask", false),
|
||||
pressure: pressure_list(p.get("pressure"), pts_len)?,
|
||||
};
|
||||
let connect = bool_or(p, "connect", false);
|
||||
let mut connected = false;
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
|
||||
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
|
||||
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
@@ -271,7 +322,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
// the previous stroke's end and its brush values (not its points)
|
||||
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
|
||||
Some((
|
||||
*l.points.last()?,
|
||||
BrushStroke {
|
||||
points: Vec::new(),
|
||||
size: l.size,
|
||||
feather: l.feather,
|
||||
flow: l.flow,
|
||||
// the pressure the last dab was painted with, when the stroke recorded any
|
||||
pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] },
|
||||
..BrushStroke::default()
|
||||
},
|
||||
))
|
||||
});
|
||||
match anchor {
|
||||
Some((from, last)) => {
|
||||
strokes.extend(connected_strokes(from, &last, stroke));
|
||||
connected = true;
|
||||
}
|
||||
None => strokes.push(stroke),
|
||||
}
|
||||
} else {
|
||||
m.components.push(MaskComponent {
|
||||
name: None,
|
||||
@@ -281,7 +353,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("connected".into(), json!(connected));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
),
|
||||
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
|
||||
|
||||
@@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let (kind, finish) = crate::merge::parse(id, p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
let ids: Vec<PhotoId> = s.targets(p)?;
|
||||
if !bool_or(p, "preview", false) {
|
||||
return s.merge_now(kind, finish, &ids);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "merge.hdrBatch";
|
||||
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
let ids: Vec<PhotoId> = s.targets(p)?;
|
||||
let sets = s.catalog.bracket_sets(&ids);
|
||||
if sets.is_empty() {
|
||||
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
mod focus;
|
||||
pub mod geocode;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
@@ -34,6 +35,7 @@ mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
mod slideshow;
|
||||
@@ -109,7 +111,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
/// can take (issue #684; Add to My Photos first).
|
||||
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
}
|
||||
/// Why an album command refused a Local-only selection.
|
||||
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
|
||||
@@ -160,6 +162,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(prefs::specs());
|
||||
v.extend(slideshow::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(print::specs());
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
@@ -172,6 +175,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(face_models::specs());
|
||||
v.extend(face_detect::specs());
|
||||
v.extend(face_recognize::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(activity::specs());
|
||||
v
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> {
|
||||
|
||||
fn in_stack(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_stacks(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let top = p
|
||||
.get("top")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -153,11 +153,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p));
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p)?);
|
||||
Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p));
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?);
|
||||
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
|
||||
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p));
|
||||
let stacks = stacks_of(s, &s.targets(p)?);
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
let collapsed = bool_or(p, "collapsed", !first);
|
||||
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
|
||||
@@ -212,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let gap = f64_or(p, "gap", 60.0).max(0.0);
|
||||
let explicit = p.get("ids").is_some();
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
let groups = s.catalog.auto_stack_groups(&pool, gap);
|
||||
let photos: usize = groups.iter().map(Vec::len).sum();
|
||||
if bool_or(p, "preview", false) {
|
||||
|
||||
@@ -57,10 +57,20 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(g) = &group {
|
||||
it.preset.group = g.clone();
|
||||
}
|
||||
// the profile it names (issue #1001): one the user imported, or ours for a basic one
|
||||
let mut profile = Value::Null;
|
||||
if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) {
|
||||
let look = it.preset.settings["profile"]["look"].clone();
|
||||
it.unmapped.retain(|u| u != "Look");
|
||||
it.unmapped.extend(crate::profile_refs::report(&look, &r));
|
||||
profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")});
|
||||
}
|
||||
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
|
||||
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
|
||||
match id {
|
||||
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
|
||||
Some(id) => {
|
||||
imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile}))
|
||||
}
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
@@ -108,7 +118,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\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
|
||||
"{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; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
// explicit ids, else the selection, else everything in view
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
@@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 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 };
|
||||
@@ -503,7 +503,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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 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()) {
|
||||
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
|
||||
_ => true,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! `print.*`: the Print module as commands.
|
||||
//!
|
||||
//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the
|
||||
//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at
|
||||
//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches
|
||||
//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json.
|
||||
//!
|
||||
//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset`
|
||||
//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear`
|
||||
//! * `print.exportPdf {path, ids?}`
|
||||
//! * `print.template.list|save|apply|delete`
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, f64_req};
|
||||
use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size};
|
||||
use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn rect_json(r: &Rect) -> Value {
|
||||
json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h})
|
||||
}
|
||||
|
||||
/// The state, with what a front end needs to draw it (page size, printable area, grid cells).
|
||||
fn state(s: &Session, photos: usize) -> Value {
|
||||
let l = &s.print.layout;
|
||||
let mut out = json!({
|
||||
"settings": s.print,
|
||||
"papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::<Vec<_>>(),
|
||||
"overlaps": l.has_overlaps(),
|
||||
"photos": photos,
|
||||
});
|
||||
if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) {
|
||||
o.insert("pageWidth".into(), json!(w));
|
||||
o.insert("pageHeight".into(), json!(h));
|
||||
o.insert("printable".into(), rect_json(&area));
|
||||
o.insert("pageCount".into(), json!(l.page_count(photos)));
|
||||
o.insert("cellsPerPage".into(), json!(l.cells_per_page()));
|
||||
if !l.style.is_package() {
|
||||
o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reply(s: &Session) -> Result<Value> {
|
||||
Ok(state(s, s.targets(&Value::Null)?.len()))
|
||||
}
|
||||
|
||||
/// Applies `patch` over `dst`: objects merge key by key, anything else replaces.
|
||||
fn merge(dst: &mut Value, patch: &Value, depth: usize) {
|
||||
match (dst, patch) {
|
||||
(Value::Object(d), Value::Object(p)) if depth < 8 => {
|
||||
for (k, v) in p {
|
||||
match d.get_mut(k) {
|
||||
Some(slot) => merge(slot, v, depth + 1),
|
||||
None => {
|
||||
d.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, p) => *d = p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state(s, s.targets(p)?.len()))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.set";
|
||||
let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?;
|
||||
if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)")));
|
||||
}
|
||||
if let Some(l) = patch.get("layout").and_then(Value::as_object)
|
||||
&& let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId"))
|
||||
{
|
||||
return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands")));
|
||||
}
|
||||
let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?;
|
||||
merge(&mut value, p, 0);
|
||||
let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?;
|
||||
// A new paper, orientation or margin may leave cells outside the page: pull them back.
|
||||
if next.layout.page.printable().is_ok() {
|
||||
next.layout.fit_cells().map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
next.validate().map_err(|e| bad(ID, e))?;
|
||||
s.print = next;
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print = PrintSettings::default();
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn cell_id(p: &Value, id: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`"))
|
||||
}
|
||||
|
||||
fn opt_f64(p: &Value, key: &str, id: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps `r` (a cell being moved or resized) when the call asks for it.
|
||||
fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result<Rect> {
|
||||
let on = match p.get("snap") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`snap` must be true or false")),
|
||||
};
|
||||
if !on || guides.snap == Snap::Off {
|
||||
return Ok(r);
|
||||
}
|
||||
let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1);
|
||||
let f = if resize { Layout::snap_resize } else { Layout::snap_move };
|
||||
f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn cell_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.add";
|
||||
let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?);
|
||||
let mut next = s.print.layout.clone();
|
||||
let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?;
|
||||
if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) {
|
||||
if let Some(page) = p.get("page").and_then(Value::as_u64) {
|
||||
let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?;
|
||||
next.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
next.move_cell(id, x, y).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
if let Some(v) = p.get("photo").filter(|v| !v.is_null()) {
|
||||
let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?;
|
||||
next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
s.print.layout = next;
|
||||
let cell = s.print.layout.cell(id).cloned();
|
||||
Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages}))
|
||||
}
|
||||
|
||||
fn cell_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.move";
|
||||
let id = cell_id(p, ID)?;
|
||||
let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?);
|
||||
let mut next = s.print.layout.clone();
|
||||
let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
|
||||
let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?;
|
||||
let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?;
|
||||
s.print.layout = next;
|
||||
Ok(json!({"id": id, "rect": r}))
|
||||
}
|
||||
|
||||
fn cell_resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.resize";
|
||||
let id = cell_id(p, ID)?;
|
||||
let mut next = s.print.layout.clone();
|
||||
let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
|
||||
let want = Rect::new(
|
||||
opt_f64(p, "x", ID)?.unwrap_or(cur.x),
|
||||
opt_f64(p, "y", ID)?.unwrap_or(cur.y),
|
||||
opt_f64(p, "width", ID)?.unwrap_or(cur.w),
|
||||
opt_f64(p, "height", ID)?.unwrap_or(cur.h),
|
||||
);
|
||||
let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?;
|
||||
let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?;
|
||||
s.print.layout = next;
|
||||
Ok(json!({"id": id, "rect": r}))
|
||||
}
|
||||
|
||||
fn cell_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.delete";
|
||||
let id = cell_id(p, ID)?;
|
||||
s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()}))
|
||||
}
|
||||
|
||||
fn cell_stack(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.stack";
|
||||
let id = cell_id(p, ID)?;
|
||||
let how = match p.get("order").and_then(Value::as_str) {
|
||||
Some("toBack") => Stack::ToBack,
|
||||
Some("backward") => Stack::Backward,
|
||||
Some("forward") => Stack::Forward,
|
||||
Some("toFront") => Stack::ToFront,
|
||||
_ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")),
|
||||
};
|
||||
s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn cell_photo(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.photo";
|
||||
let id = cell_id(p, ID)?;
|
||||
let photo = match p.get("photo") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?;
|
||||
if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() {
|
||||
return Err(bad(ID, format!("no photo {n}")));
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
};
|
||||
s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"id": id, "photo": photo}))
|
||||
}
|
||||
|
||||
fn cell_page(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.page";
|
||||
let id = cell_id(p, ID)?;
|
||||
let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?;
|
||||
s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"id": id, "page": page}))
|
||||
}
|
||||
|
||||
fn auto_layout(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?;
|
||||
Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells}))
|
||||
}
|
||||
|
||||
fn new_page(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?;
|
||||
Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print.layout.clear();
|
||||
Ok(json!({"pages": 1, "cells": 0}))
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.exportPdf";
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)")));
|
||||
}
|
||||
if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) {
|
||||
return Err(bad(ID, "ids must be an array of photo ids"));
|
||||
}
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err(bad(ID, "id must be a photo id"));
|
||||
}
|
||||
s.check_write_target(path).map_err(|e| bad(ID, e))?;
|
||||
let ids = s.targets(p)?;
|
||||
let settings = s.print.clone();
|
||||
// a printer profile that can't be used fails the print before anything renders
|
||||
crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?;
|
||||
let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string();
|
||||
let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?;
|
||||
let total = work.len() as u64;
|
||||
let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No);
|
||||
row.progress(0, total);
|
||||
let pages = work
|
||||
.run(&mut |done, name| {
|
||||
row.progress(done as u64, total);
|
||||
row.detail(name);
|
||||
true
|
||||
})
|
||||
.map_err(|e| bad(ID, e))?;
|
||||
let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?;
|
||||
s.check_write_target(path).map_err(|e| bad(ID, e))?;
|
||||
crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
// ---- templates ------------------------------------------------------------------------------
|
||||
|
||||
fn template_list(s: &Session) -> Value {
|
||||
json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn template_name(p: &Value, id: &str) -> Result<String> {
|
||||
clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e))
|
||||
}
|
||||
|
||||
fn template_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.save";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?;
|
||||
match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = t,
|
||||
None => {
|
||||
if s.print_templates.len() >= MAX_TEMPLATES {
|
||||
return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first")));
|
||||
}
|
||||
s.print_templates.push(t);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
fn template_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.apply";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = s
|
||||
.print_templates
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(&name))
|
||||
.ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?;
|
||||
s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?;
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn template_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.delete";
|
||||
let name = template_name(p, ID)?;
|
||||
let before = s.print_templates.len();
|
||||
s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if s.print_templates.len() == before {
|
||||
return Err(bad(ID, format!("no print template `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "print.get", "Print Settings", [], None,
|
||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||
always, get),
|
||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
always, set),
|
||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||
"{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given",
|
||||
always, cell_add),
|
||||
cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move),
|
||||
cmd!(query "print.cell.resize", "Resize Print Cell", [], None,
|
||||
"{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize),
|
||||
cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete),
|
||||
cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack),
|
||||
cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo),
|
||||
cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page),
|
||||
cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout),
|
||||
cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page),
|
||||
cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear),
|
||||
cmd!(query "print.exportPdf", "Print to PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original",
|
||||
always, export_pdf),
|
||||
cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))),
|
||||
cmd!(query "print.template.save", "Save Print Template", [], None,
|
||||
"{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list",
|
||||
always, template_save),
|
||||
cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply),
|
||||
cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete),
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
|
||||
for id in ids {
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
@@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut read = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
match s.read_sidecar_op_with_warnings(id) {
|
||||
Ok(Some((op, from, mut photo_warnings))) => {
|
||||
ops.push(op);
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err("id must be a photo id".into());
|
||||
}
|
||||
let ids = session.targets(p);
|
||||
let ids = session.targets(p).map_err(|e| e.to_string())?;
|
||||
if ids.is_empty() || ids.len() > 1000 {
|
||||
return Err("select between 1 and 1000 photos".into());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ pub fn load(s: &mut Session) {
|
||||
creator: "LightCraft Demo".into(),
|
||||
..Default::default()
|
||||
};
|
||||
// one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so
|
||||
// View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file
|
||||
if sc.name == "Cosmos bloom" {
|
||||
p.meta.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 },
|
||||
mode: Some("AF-S".into()),
|
||||
area: Some("Flexible Spot".into()),
|
||||
tracking: None,
|
||||
});
|
||||
}
|
||||
p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8];
|
||||
p.flag = match sc.id % 6 {
|
||||
1 => Flag::Pick,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
|
||||
//! originals a Lightroom catalog lists.
|
||||
//!
|
||||
//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
|
||||
//! document's folder, and only local files, or files on the network share the document itself is
|
||||
//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
|
||||
//! file.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
|
||||
/// Why a stored path is not followed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotFollowed {
|
||||
/// The path is on another computer (other than the document's share), names a device, is a
|
||||
/// URL, or is not a usable path.
|
||||
Refused(Refused),
|
||||
/// The path is not an absolute path on this computer, such as a Windows drive path read on
|
||||
/// macOS or Linux.
|
||||
NotHere,
|
||||
}
|
||||
|
||||
impl fmt::Display for NotFollowed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Refused(r) => r.fmt(f),
|
||||
Self::NotHere => f.write_str("the path is not a path on this computer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotFollowed {}
|
||||
|
||||
/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
|
||||
/// or why it is not followed. A relative `document` is taken from the working directory, where it
|
||||
/// was opened from.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
|
||||
let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
|
||||
let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// document does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NotHere);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
|
||||
let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
|
||||
assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
|
||||
assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
|
||||
assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
|
||||
}
|
||||
}
|
||||
+114
-12
@@ -185,6 +185,17 @@ impl DestinationMode {
|
||||
}
|
||||
}
|
||||
|
||||
impl Conflict {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"unique" => Some(Self::Unique),
|
||||
"overwrite" => Some(Self::Overwrite),
|
||||
"skip" => Some(Self::Skip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of `app.export` params.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportPreset {
|
||||
@@ -214,6 +225,18 @@ pub enum SharpenFor {
|
||||
Glossy,
|
||||
}
|
||||
|
||||
impl SharpenFor {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"none" => Some(Self::None),
|
||||
"screen" => Some(Self::Screen),
|
||||
"matte" => Some(Self::Matte),
|
||||
"glossy" => Some(Self::Glossy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SharpenAmount {
|
||||
@@ -223,6 +246,17 @@ pub enum SharpenAmount {
|
||||
High,
|
||||
}
|
||||
|
||||
impl SharpenAmount {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"low" => Some(Self::Low),
|
||||
"standard" => Some(Self::Standard),
|
||||
"high" => Some(Self::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a watermark sits.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -537,6 +571,18 @@ pub enum MetadataPolicy {
|
||||
None,
|
||||
}
|
||||
|
||||
impl MetadataPolicy {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"all" => Some(Self::All),
|
||||
"allexceptcamera" => Some(Self::AllExceptCamera),
|
||||
"copyright" => Some(Self::Copyright),
|
||||
"none" => Some(Self::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or
|
||||
/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -814,6 +860,10 @@ impl ExportOptions {
|
||||
let s = string(cmd, k, v)?;
|
||||
if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
// a choice `from_json` reads with the same `parse`, so what passes here is what gets exported
|
||||
let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> {
|
||||
if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
@@ -869,10 +919,10 @@ impl ExportOptions {
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?,
|
||||
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?,
|
||||
"destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
@@ -880,7 +930,7 @@ impl ExportOptions {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?;
|
||||
}
|
||||
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"colorSpace" => {
|
||||
if OutputSpace::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020")));
|
||||
@@ -982,13 +1032,13 @@ impl ExportOptions {
|
||||
resize: Self::resize_from_json(p),
|
||||
ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16),
|
||||
limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32),
|
||||
sharpen: enm(p, "sharpen").unwrap_or(d.sharpen),
|
||||
sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount),
|
||||
sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen),
|
||||
sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount),
|
||||
naming: s("naming").map_or(d.naming, str::to_string),
|
||||
start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32),
|
||||
destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode),
|
||||
subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")),
|
||||
conflict: enm(p, "conflict").unwrap_or(d.conflict),
|
||||
conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict),
|
||||
tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("none") => TiffCompression::None,
|
||||
Some("lzw") => TiffCompression::Lzw,
|
||||
@@ -996,7 +1046,7 @@ impl ExportOptions {
|
||||
_ => d.tiff_compression,
|
||||
},
|
||||
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
|
||||
metadata: enm(p, "metadata").unwrap_or(d.metadata),
|
||||
metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata),
|
||||
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
|
||||
watermark: match p.get("watermark") {
|
||||
Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }),
|
||||
@@ -2457,7 +2507,7 @@ mod tests {
|
||||
fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() {
|
||||
use ab_glyph::Font;
|
||||
let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else {
|
||||
eprintln!("skipped: built without BIZ UDMincho from craft-fonts");
|
||||
lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts");
|
||||
return;
|
||||
};
|
||||
let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap();
|
||||
@@ -2529,7 +2579,7 @@ mod tests {
|
||||
compare(composed, decomposed, crate::fonts::CRAFT_FONTS);
|
||||
}
|
||||
} else {
|
||||
eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts");
|
||||
lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2553,7 +2603,7 @@ mod tests {
|
||||
use ab_glyph::Font;
|
||||
let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS);
|
||||
if fonts.len() < 2 {
|
||||
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
|
||||
lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
|
||||
return;
|
||||
}
|
||||
for c in "日本語の文字".chars() {
|
||||
@@ -3182,6 +3232,58 @@ mod tests {
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o);
|
||||
}
|
||||
|
||||
/// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the
|
||||
/// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and
|
||||
/// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and
|
||||
/// `conflict` had the same gap.
|
||||
#[test]
|
||||
fn accepted_choices_are_the_ones_exported() {
|
||||
use serde_json::json;
|
||||
let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string());
|
||||
let metadata = [
|
||||
("all", MetadataPolicy::All),
|
||||
("allExceptCamera", MetadataPolicy::AllExceptCamera),
|
||||
("copyright", MetadataPolicy::Copyright),
|
||||
("none", MetadataPolicy::None),
|
||||
];
|
||||
for (name, want) in metadata {
|
||||
for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] {
|
||||
assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}");
|
||||
}
|
||||
}
|
||||
for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)]
|
||||
{
|
||||
assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want);
|
||||
}
|
||||
for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] {
|
||||
assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want);
|
||||
}
|
||||
for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] {
|
||||
assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want);
|
||||
}
|
||||
// an explicit value nothing reads as is an error, never the permissive default
|
||||
for v in ["", " none", "nothing", "all-except-camera"] {
|
||||
let e = read("metadata", v).unwrap_err();
|
||||
assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}");
|
||||
}
|
||||
assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high"));
|
||||
assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip"));
|
||||
// absent (or null) keeps the defaults
|
||||
let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap();
|
||||
assert_eq!(
|
||||
(d.metadata, d.sharpen, d.sharpen_amount, d.conflict),
|
||||
(MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique)
|
||||
);
|
||||
// `removeLocation` stays independent of the policy's spelling
|
||||
let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap();
|
||||
assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true));
|
||||
// stored settings (lenient `from_json`) read the same spellings
|
||||
assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None);
|
||||
// and `to_json` still writes what the MCP schema lists
|
||||
let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json();
|
||||
assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip")));
|
||||
}
|
||||
|
||||
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
|
||||
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
|
||||
#[test]
|
||||
@@ -3264,7 +3366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() {
|
||||
if !lightcraft_gpu::available() {
|
||||
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
let mut s = crate::Session::with_demo();
|
||||
|
||||
@@ -238,7 +238,7 @@ const SCAN_MAX_FILE: u64 = 64 << 20;
|
||||
|
||||
/// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft
|
||||
/// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to).
|
||||
#[derive(Default)]
|
||||
/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag).
|
||||
pub(crate) struct Scanned {
|
||||
set: std::collections::HashSet<u64>,
|
||||
unsaved: Vec<u64>,
|
||||
@@ -246,9 +246,23 @@ pub(crate) struct Scanned {
|
||||
file_matches: bool,
|
||||
/// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save.
|
||||
rewrite: bool,
|
||||
magic: [u8; 4],
|
||||
/// What made the record: a file made by anything else is started afresh.
|
||||
tag: String,
|
||||
}
|
||||
|
||||
impl Default for Scanned {
|
||||
fn default() -> Self {
|
||||
Scanned::new(*SCAN_MAGIC, SCAN_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
impl Scanned {
|
||||
/// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`.
|
||||
pub fn new(magic: [u8; 4], tag: &str) -> Scanned {
|
||||
Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, rewrite: false, magic, tag: tag.to_string() }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
@@ -292,12 +306,13 @@ impl Scanned {
|
||||
}
|
||||
}
|
||||
|
||||
fn header() -> Vec<u8> {
|
||||
fn header(&self) -> Vec<u8> {
|
||||
let mut h = Vec::new();
|
||||
h.extend_from_slice(SCAN_MAGIC);
|
||||
h.extend_from_slice(&self.magic);
|
||||
h.push(SCAN_VERSION);
|
||||
h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(SCAN_TAG.as_bytes());
|
||||
let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes());
|
||||
h.extend_from_slice(&(tag.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(tag);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -307,7 +322,7 @@ impl Scanned {
|
||||
return;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else { return };
|
||||
let head = Self::header();
|
||||
let head = self.header();
|
||||
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
|
||||
self.file_matches = true;
|
||||
// a half-written last id (a crash while appending) is ignored
|
||||
@@ -330,7 +345,7 @@ impl Scanned {
|
||||
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
|
||||
f.write_all(&buf)?;
|
||||
} else {
|
||||
let mut buf = Self::header();
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
|
||||
@@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize {
|
||||
cap.map_or(n, |c| n.min(c.max(1)))
|
||||
}
|
||||
|
||||
/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be
|
||||
/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders
|
||||
/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes.
|
||||
pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) {
|
||||
match pace {
|
||||
Pace::Pause | Pace::Light => (1, 0.25),
|
||||
Pace::Normal => (2, 0.75),
|
||||
Pace::Full => (4, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// One photo's faces to embed (and, if `detect`, to find first).
|
||||
pub(crate) struct Prepared {
|
||||
/// `<model id>@<sha256>` the embeddings are for.
|
||||
@@ -201,7 +212,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
let img = Rgb { data: &rgb, width: w, height: h };
|
||||
let (iw, ih) = embedder.input_size();
|
||||
let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32));
|
||||
let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo
|
||||
// every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo)
|
||||
let mut faces: Vec<(Rect, Option<Vec<u8>>, Rect)> = todo
|
||||
.into_iter()
|
||||
.map(|rect| {
|
||||
let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0));
|
||||
@@ -218,20 +230,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
ih,
|
||||
),
|
||||
};
|
||||
let v = crop.and_then(|c| embedder.embed(&c).ok());
|
||||
// shown by the detector's box where it found this face: every face then has the same tightness
|
||||
let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect);
|
||||
(rect, v, view)
|
||||
(rect, crop, view)
|
||||
})
|
||||
.collect();
|
||||
// a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass
|
||||
let new_faces = if detect {
|
||||
detected.map(|faces| {
|
||||
detected.map(|found| {
|
||||
let mut rects = Vec::new();
|
||||
for f in faces.iter().filter(|f| f.score >= REGION_SCORE) {
|
||||
for f in found.iter().filter(|f| f.score >= REGION_SCORE) {
|
||||
let Some(rect) = face_rect(f) else { continue };
|
||||
let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok());
|
||||
results.push((rect, v, rect));
|
||||
faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect));
|
||||
rects.push(rect);
|
||||
}
|
||||
rects
|
||||
@@ -239,6 +249,10 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect();
|
||||
let mut embedded = embedder.embed_batch(&crops).into_iter();
|
||||
let results: Vec<(Rect, Option<Vec<f32>>, Rect)> =
|
||||
faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect();
|
||||
(results, new_faces)
|
||||
}));
|
||||
let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None));
|
||||
|
||||
+78
-24
@@ -41,6 +41,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
|
||||
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
|
||||
regions: m.regions.clone(),
|
||||
focus: m.focus.clone(),
|
||||
};
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
@@ -159,10 +160,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
}
|
||||
Err(Err(e)) => return Err(e),
|
||||
};
|
||||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||||
if w <= 1 || h <= 1 {
|
||||
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
|
||||
}
|
||||
let (w, h) = raw.developed_size();
|
||||
let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32);
|
||||
if raw.orientation.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
@@ -267,19 +266,43 @@ impl CameraColourModel<'_> {
|
||||
/// White balance, matrix, tables and baseline exposure, in place.
|
||||
pub fn apply(&self, img: &mut Rgb32f) {
|
||||
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match self.tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
});
|
||||
if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) {
|
||||
// a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits)
|
||||
const LANES: usize = 16;
|
||||
lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| {
|
||||
for chunk in row.chunks_mut(LANES) {
|
||||
let mut rgb = [[0.0f32; LANES]; 3];
|
||||
for (l, p) in chunk.iter().enumerate() {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
for (i, rgb) in rgb.iter_mut().enumerate() {
|
||||
rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain;
|
||||
}
|
||||
}
|
||||
let out = hue_sat.apply_lanes(rgb);
|
||||
for (l, p) in chunk.iter_mut().enumerate() {
|
||||
*p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0));
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
img.map_in_place(|p| self.pixel(&m, p));
|
||||
}
|
||||
|
||||
/// [`Self::apply`] to one pixel; `m` is the matrix as `f32`.
|
||||
fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (wb, gain) = (self.wb, self.gain);
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
let rgb = match self.tables {
|
||||
Some(tables) => tables.apply(rgb, gain),
|
||||
None => rgb.map(|v| v * gain),
|
||||
};
|
||||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,12 +424,17 @@ fn load_bytes_now(
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let info = raw.info();
|
||||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||||
// (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it)
|
||||
let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
|
||||
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
|
||||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
|
||||
let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info));
|
||||
let ((xy, t, camera_look), mattes) = rayon::join(
|
||||
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()),
|
||||
|| dng_mattes(&bytes, &info),
|
||||
);
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
@@ -476,10 +504,11 @@ fn load_bytes_now(
|
||||
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
|
||||
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
|
||||
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
|
||||
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng))
|
||||
.then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let local_tone = local_tone(&raw);
|
||||
let (temp, tint) = scale.as_shot(xy);
|
||||
@@ -1640,7 +1669,7 @@ pub(crate) mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
|
||||
@@ -1807,6 +1836,31 @@ pub(crate) mod tests {
|
||||
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
|
||||
}
|
||||
|
||||
/// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the
|
||||
/// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included.
|
||||
#[test]
|
||||
fn camera_look_in_lanes_matches_pixel_by_pixel() {
|
||||
let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect();
|
||||
let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true };
|
||||
let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap();
|
||||
let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) };
|
||||
let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN];
|
||||
let img = Rgb32f::from_fn(37, 5, |x, y| {
|
||||
let k = y * 37 + x;
|
||||
if k % 5 == 0 {
|
||||
[special[k % 10], special[k / 10 % 10], special[k / 100 % 10]]
|
||||
} else {
|
||||
[(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05]
|
||||
}
|
||||
});
|
||||
let mut lanes = img.clone();
|
||||
colour.apply(&mut lanes);
|
||||
let m = colour.matrix.to_f32();
|
||||
for (p, got) in img.data.iter().zip(&lanes.data) {
|
||||
assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_white_renders_neutral_through_the_colour_model() {
|
||||
use lightcraft_color::{LUMA_2020, Mat3};
|
||||
@@ -1991,7 +2045,7 @@ pub(crate) mod tests {
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
let (img, _) = load_bytes(&bytes, 1600).unwrap();
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Reading the camera's focus point for photos already in the library (issue #1480).
|
||||
//!
|
||||
//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job
|
||||
//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body
|
||||
//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few
|
||||
//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1
|
||||
//! ARW), one file at a time on one worker thread.
|
||||
//!
|
||||
//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping
|
||||
//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only
|
||||
//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path.
|
||||
//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in
|
||||
//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the
|
||||
//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are
|
||||
//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded:
|
||||
//! it is tried again the next time the library opens.
|
||||
//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's
|
||||
//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between
|
||||
//! files while they are around.
|
||||
//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand
|
||||
//! with `photo.readFocusPoints`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_meta::FocusPoint;
|
||||
use lightcraft_meta::focus::HeaderRead;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::activity::{Cancel, TaskGuard};
|
||||
use crate::faces_index::Scanned;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The record of photos looked at with nothing found, beside the library.
|
||||
pub(crate) const RECORD_FILE: &str = "focus-scanned.bin";
|
||||
const RECORD_MAGIC: [u8; 4] = *b"LCFP";
|
||||
/// Photos committed at once.
|
||||
pub(crate) const COMMIT_EVERY: usize = 200;
|
||||
/// What has been read is committed at least this often, so points appear while a long run goes on.
|
||||
const COMMIT_AFTER: Duration = Duration::from_secs(3);
|
||||
/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first.
|
||||
const LIGHT_REST: Duration = Duration::from_millis(40);
|
||||
/// How often a paused worker looks at its pace again.
|
||||
const PAUSE_POLL: Duration = Duration::from_millis(50);
|
||||
/// Results taken in per pump (the rest wait for the next frame).
|
||||
const MAX_DRAIN: usize = 5000;
|
||||
|
||||
/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads).
|
||||
const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"];
|
||||
|
||||
/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub(crate) enum Pace {
|
||||
Pause = 0,
|
||||
Light = 1,
|
||||
Normal = 2,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
pub fn parse(name: Option<&str>) -> Pace {
|
||||
match name {
|
||||
Some("pause") => Pace::Pause,
|
||||
Some("light") => Pace::Light,
|
||||
_ => Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The focus point in the file at `path`, read from its header only.
|
||||
pub fn read_focus(path: &str) -> std::result::Result<HeaderRead, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
|
||||
lightcraft_meta::focus::read_header(len, &mut |off, buf| {
|
||||
file.seek(SeekFrom::Start(off))?;
|
||||
file.read_exact(buf)
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// What the worker says about one photo.
|
||||
enum Outcome {
|
||||
/// The header was read: the point it holds, or none.
|
||||
Read { id: PhotoId, focus: Option<FocusPoint>, bytes: u64 },
|
||||
/// The file couldn't be read (offline, gone, changed under the read): tried again another time.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Summary {
|
||||
pub total: u64,
|
||||
pub done: u64,
|
||||
pub found: u64,
|
||||
pub none: u64,
|
||||
pub unavailable: u64,
|
||||
pub bytes: u64,
|
||||
pub seconds: f64,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn json(&self) -> Value {
|
||||
json!({
|
||||
"total": self.total,
|
||||
"done": self.done,
|
||||
"found": self.found,
|
||||
"none": self.none,
|
||||
"unavailable": self.unavailable,
|
||||
"bytesRead": self.bytes,
|
||||
"seconds": self.seconds,
|
||||
"cancelled": self.cancelled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
identity: Arc<()>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
pace: Arc<AtomicU8>,
|
||||
rx: mpsc::Receiver<(Outcome, String)>,
|
||||
/// Its row in the activity stack (dropping the job removes it).
|
||||
guard: TaskGuard,
|
||||
/// The path each photo was read at: a photo moved or relinked since is left for another run.
|
||||
paths: HashMap<PhotoId, String>,
|
||||
pending: Vec<(PhotoId, Option<FocusPoint>)>,
|
||||
committed_at: Instant,
|
||||
started: Instant,
|
||||
summary: Summary,
|
||||
}
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
// the worker stops before its next file (and when it can't hand a result over)
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The backfill's state in the session.
|
||||
pub(crate) struct FocusScan {
|
||||
record: Scanned,
|
||||
/// The library opening the record and the automatic start belong to.
|
||||
library: Option<Arc<()>>,
|
||||
auto_checked: bool,
|
||||
job: Option<Job>,
|
||||
last: Option<Summary>,
|
||||
/// The pace the UI last gave (a job started between two pumps starts at it).
|
||||
pace: Pace,
|
||||
}
|
||||
|
||||
impl Default for FocusScan {
|
||||
fn default() -> Self {
|
||||
FocusScan {
|
||||
record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()),
|
||||
library: None,
|
||||
auto_checked: false,
|
||||
job: None,
|
||||
last: None,
|
||||
pace: Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and
|
||||
/// are read again there next time) and loads that library's record.
|
||||
fn follow_library(s: &mut Session) {
|
||||
if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) {
|
||||
return;
|
||||
}
|
||||
s.focus_scan.job = None;
|
||||
s.focus_scan.auto_checked = false;
|
||||
let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE));
|
||||
s.focus_scan.record.reset(path);
|
||||
s.focus_scan.library = Some(Arc::clone(&s.library_identity));
|
||||
}
|
||||
|
||||
/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file;
|
||||
/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together).
|
||||
fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> {
|
||||
let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> {
|
||||
if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) {
|
||||
return None;
|
||||
}
|
||||
let Source::File { path } = &ph.source else { return None };
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone()))
|
||||
};
|
||||
let mut out: Vec<(PhotoId, String)> = match ids {
|
||||
Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(),
|
||||
None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(),
|
||||
};
|
||||
out.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
out.dedup_by_key(|c| c.0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Start reading `list` on a worker thread.
|
||||
fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let pace = Arc::new(AtomicU8::new(pace as u8));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let paths: HashMap<PhotoId, String> = list.iter().cloned().collect();
|
||||
let total = list.len() as u64;
|
||||
let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("focus-points".into())
|
||||
.spawn(move || {
|
||||
for (id, path) in list {
|
||||
loop {
|
||||
if worker_cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(PAUSE_POLL);
|
||||
}
|
||||
if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 {
|
||||
std::thread::sleep(LIGHT_REST);
|
||||
}
|
||||
// a decoder bug in one file is that file's problem, not the job's end
|
||||
let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) {
|
||||
Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read },
|
||||
Ok(Err(e)) => {
|
||||
log::debug!("focus point: {e}");
|
||||
Outcome::Unavailable
|
||||
}
|
||||
Err(_) => Outcome::Unavailable,
|
||||
};
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if tx.send((outcome, name)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("could not start reading focus points: {e}"))?;
|
||||
let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone()));
|
||||
guard.progress(0, total);
|
||||
let now = Instant::now();
|
||||
s.focus_scan.job = Some(Job {
|
||||
identity: Arc::clone(&s.library_identity),
|
||||
cancel,
|
||||
pace,
|
||||
rx,
|
||||
guard,
|
||||
paths,
|
||||
pending: Vec::new(),
|
||||
committed_at: now,
|
||||
started: now,
|
||||
summary: Summary { total, ..Default::default() },
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none
|
||||
/// (to the record).
|
||||
fn commit(s: &mut Session) {
|
||||
let Some(job) = s.focus_scan.job.as_mut() else { return };
|
||||
let pending = std::mem::take(&mut job.pending);
|
||||
job.committed_at = Instant::now();
|
||||
if !Arc::ptr_eq(&job.identity, &s.library_identity) {
|
||||
return;
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut none = Vec::new();
|
||||
for (id, focus) in pending {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read);
|
||||
// deleted, moved, relinked or given a point meanwhile: leave it
|
||||
if !same_file || !ph.in_library() || ph.meta.focus.is_some() {
|
||||
continue;
|
||||
}
|
||||
match focus {
|
||||
Some(f) => {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.focus = Some(f);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
None => none.push(id.0),
|
||||
}
|
||||
}
|
||||
for id in none {
|
||||
s.focus_scan.record.insert(id);
|
||||
}
|
||||
if let Err(e) = s.focus_scan.record.save() {
|
||||
log::warn!("could not save the list of photos read for focus points: {e}");
|
||||
}
|
||||
if ops.is_empty() {
|
||||
return;
|
||||
}
|
||||
match s.apply_system(Op::Batch { ops }) {
|
||||
// the focus point is never written to XMP: no sidecar is rewritten for it
|
||||
Ok(()) => s.skip_auto_write = true,
|
||||
Err(e) => log::warn!("could not store focus points: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled.
|
||||
/// Returns whether a job is still running.
|
||||
fn step(s: &mut Session, pace: Pace) -> bool {
|
||||
let Some(job) = s.focus_scan.job.as_mut() else { return false };
|
||||
job.pace.store(pace as u8, Ordering::Relaxed);
|
||||
let mut finished = job.cancel.load(Ordering::Relaxed);
|
||||
let mut last_name = None;
|
||||
for _ in 0..MAX_DRAIN {
|
||||
match job.rx.try_recv() {
|
||||
Ok((outcome, name)) => {
|
||||
job.summary.done += 1;
|
||||
last_name = Some(name);
|
||||
match outcome {
|
||||
Outcome::Read { id, focus, bytes } => {
|
||||
job.summary.bytes = job.summary.bytes.saturating_add(bytes);
|
||||
if focus.is_some() {
|
||||
job.summary.found += 1;
|
||||
} else {
|
||||
job.summary.none += 1;
|
||||
}
|
||||
job.pending.push((id, focus));
|
||||
}
|
||||
Outcome::Unavailable => job.summary.unavailable += 1,
|
||||
}
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
// the worker ended (all read, cancelled, or it could not go on)
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finished |= job.summary.done >= job.summary.total;
|
||||
job.guard.progress(job.summary.done, job.summary.total);
|
||||
if let Some(name) = last_name {
|
||||
job.guard.detail(&name);
|
||||
}
|
||||
if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) {
|
||||
commit(s);
|
||||
}
|
||||
if finished && let Some(job) = s.focus_scan.job.take() {
|
||||
let mut summary = job.summary.clone();
|
||||
summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total;
|
||||
summary.seconds = job.started.elapsed().as_secs_f64();
|
||||
log::info!(
|
||||
"focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s",
|
||||
summary.done,
|
||||
summary.total,
|
||||
summary.found,
|
||||
summary.none,
|
||||
summary.unavailable,
|
||||
summary.bytes / 1024,
|
||||
summary.seconds
|
||||
);
|
||||
s.focus_scan.last = Some(summary);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn status(s: &Session) -> Value {
|
||||
let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found}));
|
||||
json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()})
|
||||
}
|
||||
|
||||
/// `focus.pump {pace?}`: cheap to call every frame.
|
||||
pub(crate) fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
follow_library(s);
|
||||
let pace = Pace::parse(p.get("pace").and_then(Value::as_str));
|
||||
s.focus_scan.pace = pace;
|
||||
if !step(s, pace) && !s.focus_scan.auto_checked {
|
||||
// once per library opening: start by itself when photos are left to look at (a few KB each)
|
||||
s.focus_scan.auto_checked = true;
|
||||
let list = candidates(s, None);
|
||||
if !list.is_empty()
|
||||
&& let Err(e) = start(s, list, pace)
|
||||
{
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
Ok(status(s))
|
||||
}
|
||||
|
||||
/// `photo.readFocusPoints {ids?, wait?}`.
|
||||
pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.readFocusPoints";
|
||||
follow_library(s);
|
||||
if s.focus_scan.job.is_some() {
|
||||
return Ok(json!({"queued": 0, "running": true, "status": status(s)}));
|
||||
}
|
||||
let ids: Option<Vec<PhotoId>> = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))?
|
||||
.iter()
|
||||
.map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids")))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
),
|
||||
};
|
||||
let list = candidates(s, ids.as_deref());
|
||||
let queued = list.len();
|
||||
if queued == 0 {
|
||||
return Ok(json!({"queued": 0, "running": false}));
|
||||
}
|
||||
let wait = p.get("wait").and_then(Value::as_bool) == Some(true);
|
||||
start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?;
|
||||
// a run asked for counts as this library opening's automatic one
|
||||
s.focus_scan.auto_checked = true;
|
||||
if !wait {
|
||||
return Ok(json!({"queued": queued, "running": true}));
|
||||
}
|
||||
// a script's call: read them all now (the batches still commit as they come)
|
||||
while step(s, Pace::Normal) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null);
|
||||
Ok(json!({"queued": queued, "running": false, "last": last}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn record_len(s: &Session) -> usize {
|
||||
s.focus_scan.record.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool {
|
||||
s.focus_scan.record.contains(id.0)
|
||||
}
|
||||
@@ -382,6 +382,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
|
||||
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
|
||||
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
|
||||
expand_noting(paths, skip, limits, None)
|
||||
}
|
||||
|
||||
/// [`expand_within`], handing the listing of every folder that is walked to `note`
|
||||
/// ([`crate::walk::files_in_noting`]).
|
||||
pub(crate) fn expand_noting(
|
||||
paths: &[String],
|
||||
skip: Option<&Path>,
|
||||
limits: crate::walk::Limits,
|
||||
mut note: Option<crate::walk::NoteFolder>,
|
||||
) -> (Vec<String>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
for p in paths {
|
||||
@@ -390,7 +401,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk:
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let w = crate::walk::files_in(p, skip, limits, is_supported);
|
||||
let w = match note.as_mut() {
|
||||
Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note),
|
||||
None => crate::walk::files_in(p, skip, limits, is_supported),
|
||||
};
|
||||
if w.truncated {
|
||||
log::warn!(
|
||||
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
|
||||
@@ -638,6 +652,11 @@ impl ScanInput {
|
||||
(input, paths.to_vec())
|
||||
}
|
||||
|
||||
/// The folder a scan never enters (the library's own).
|
||||
pub(crate) fn skip(&self) -> Option<&Path> {
|
||||
self.skip.as_deref()
|
||||
}
|
||||
|
||||
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
|
||||
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
|
||||
std::sync::Arc::clone(&self.library)
|
||||
@@ -656,9 +675,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
|
||||
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
|
||||
/// has) when `progress.cancel` is set.
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
scan_files(input, files, progress)
|
||||
}
|
||||
|
||||
/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]).
|
||||
pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let by_path = library_files(&input.library, &files);
|
||||
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
|
||||
@@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -
|
||||
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
|
||||
crate::files::embedded_lens(raw).or_else(|| {
|
||||
let db = database()?;
|
||||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) };
|
||||
let (w, h) = raw.developed_size();
|
||||
lookup(db, &meta(), w as f64, h as f64, raw.orientation)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
pub mod doc_path;
|
||||
mod errors;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
@@ -38,6 +39,8 @@ mod faces_index;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_worker;
|
||||
pub mod files;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod focus_scan;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
@@ -54,11 +57,16 @@ pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod model_download;
|
||||
pub mod open_path;
|
||||
pub mod originals;
|
||||
mod pdf;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod print;
|
||||
pub mod print_color;
|
||||
pub mod print_pdf;
|
||||
pub mod profile_refs;
|
||||
pub mod proof;
|
||||
pub mod rename;
|
||||
pub mod segment;
|
||||
@@ -248,6 +256,9 @@ pub struct Session {
|
||||
/// The loaded recognition model and the face embeddings made with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) faces: faces_index::FacesState,
|
||||
/// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) focus_scan: focus_scan::FocusScan,
|
||||
/// Copied develop settings (partial JSON) for Paste.
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
@@ -291,6 +302,11 @@ pub struct Session {
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
|
||||
/// with the session, so the app, CLI, control channel and MCP all see the same print.
|
||||
pub print: print::template::PrintSettings,
|
||||
/// Saved print templates (`print.template.*`), persisted in prefs.json.
|
||||
pub print_templates: Vec<print::template::PrintTemplate>,
|
||||
/// Metadata presets (`metadata.*`), persisted in prefs.json.
|
||||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||||
@@ -422,6 +438,8 @@ impl Session {
|
||||
face_catalog: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
faces: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
focus_scan: Default::default(),
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
@@ -444,6 +462,8 @@ impl Session {
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
print: Default::default(),
|
||||
print_templates: Vec::new(),
|
||||
metadata_presets: Vec::new(),
|
||||
filter_presets: Vec::new(),
|
||||
curve_presets: Vec::new(),
|
||||
@@ -500,7 +520,11 @@ impl Session {
|
||||
}
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e {
|
||||
// a shared parameter check ([`Session::targets`]) names the command it ran for
|
||||
EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg },
|
||||
e => e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
|
||||
@@ -1027,8 +1051,12 @@ impl Session {
|
||||
&& !al.is_smart()
|
||||
&& self.filter == Filter::default()
|
||||
{
|
||||
let order = al.photos.clone();
|
||||
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
// each photo's place looked up, not searched for (a big album sorted in O(n log n))
|
||||
let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len());
|
||||
for (i, id) in al.photos.iter().enumerate() {
|
||||
place.entry(*id).or_insert(i);
|
||||
}
|
||||
visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX));
|
||||
if !self.sort.ascending {
|
||||
visible.reverse();
|
||||
}
|
||||
@@ -1167,14 +1195,24 @@ impl Session {
|
||||
(self.visible_gen, self.visible.clone())
|
||||
}
|
||||
|
||||
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
|
||||
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
return a.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
/// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`].
|
||||
/// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list
|
||||
/// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo.
|
||||
pub fn targets(&self, p: &Value) -> Result<Vec<PhotoId>> {
|
||||
let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") };
|
||||
match p.get("ids") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(),
|
||||
Some(_) => return Err(invalid("ids")),
|
||||
}
|
||||
if let Some(id) = p.get("id").and_then(Value::as_u64) {
|
||||
return vec![PhotoId(id)];
|
||||
match p.get("id") {
|
||||
None | Some(Value::Null) => Ok(self.selected()),
|
||||
Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos commands act on when a call names none: the selection, else the active photo.
|
||||
pub fn selected(&self) -> Vec<PhotoId> {
|
||||
if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() }
|
||||
}
|
||||
}
|
||||
@@ -1221,6 +1259,8 @@ mod tests_face_recognize;
|
||||
mod tests_face_tiles;
|
||||
#[cfg(test)]
|
||||
mod tests_filesystem_history;
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_focus_scan;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
@@ -1246,6 +1286,8 @@ mod tests_mask_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_move_to_share;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_persist;
|
||||
@@ -1254,6 +1296,8 @@ mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_presets_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
#[cfg(test)]
|
||||
mod tests_recheck;
|
||||
|
||||
@@ -209,6 +209,8 @@ struct PrefsFile {
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Print templates.
|
||||
print_templates: Vec<crate::print::template::PrintTemplate>,
|
||||
/// Metadata presets.
|
||||
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
|
||||
/// Filter presets.
|
||||
@@ -491,6 +493,7 @@ impl Session {
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.print_templates = prefs.print_templates;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
@@ -744,6 +747,7 @@ impl Session {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
print_templates: self.print_templates.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
|
||||
@@ -538,6 +538,7 @@ mod tests {
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
@@ -621,6 +622,7 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
|
||||
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
|
||||
pub keywords: Vec<String>,
|
||||
pub history: Vec<Row>,
|
||||
pub snapshots: Vec<Row>,
|
||||
/// Whether `path` may be looked at (derived when the catalog is read; not archived).
|
||||
#[serde(skip)]
|
||||
pub original: Original,
|
||||
}
|
||||
|
||||
/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
|
||||
/// as the document ([`crate::doc_path::resolve`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Original {
|
||||
/// `path` is a local file path, or one on the network share the catalog is on.
|
||||
Local,
|
||||
/// `path` is not a path on this computer (a catalog made on another system): the photo is
|
||||
/// kept as an offline record for later relinking, and the path is not looked at.
|
||||
#[default]
|
||||
NotHere,
|
||||
/// `path` is refused (why): it is on another computer or a device, or its folder or file name
|
||||
/// is an absolute path. The photo is reported missing and not added to the library.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
impl CatalogPhoto {
|
||||
/// Whether the original is a file here; only a [`Original::Local`] path is looked at.
|
||||
pub fn original_is_file(&self) -> bool {
|
||||
self.original == Original::Local && Path::new(&self.path).is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -343,25 +368,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
|
||||
|
||||
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
|
||||
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder.
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
|
||||
/// looked at only when [`crate::doc_path::resolve`] follows them.
|
||||
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
|
||||
return None;
|
||||
}
|
||||
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
|
||||
let mut out = PathBuf::new();
|
||||
for part in dir.components() {
|
||||
match part {
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
part => out.push(part),
|
||||
}
|
||||
crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
|
||||
/// place of the root folder.
|
||||
fn is_absolute_part(part: &str) -> bool {
|
||||
let bytes = part.as_bytes();
|
||||
part.starts_with(['/', '\\'])
|
||||
|| (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
|
||||
|| Path::new(part).is_absolute()
|
||||
}
|
||||
|
||||
/// The path of an original under `root` and whether it may be looked at, resolved with the
|
||||
/// catalog at `catalog` as the document.
|
||||
fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
|
||||
let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
|
||||
if is_absolute_part(folder) || is_absolute_part(name) {
|
||||
return (stored, Original::Refused("its folder or file name is an absolute path".into()));
|
||||
}
|
||||
match crate::doc_path::resolve(&stored, Some(catalog)) {
|
||||
Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
|
||||
Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
|
||||
Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
|
||||
}
|
||||
out.is_dir().then_some(out)
|
||||
}
|
||||
|
||||
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
|
||||
@@ -457,6 +493,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out
|
||||
};
|
||||
let mut root_paths = HashMap::new();
|
||||
let mut refused_roots = HashSet::new();
|
||||
let mut root_ids: Vec<_> = roots.keys().copied().collect();
|
||||
root_ids.sort_unstable();
|
||||
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
|
||||
@@ -469,6 +506,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
}
|
||||
None => absolute.to_string(),
|
||||
};
|
||||
if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
|
||||
warnings
|
||||
.push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
|
||||
refused_roots.insert(id);
|
||||
}
|
||||
root_paths.insert(id, resolved);
|
||||
}
|
||||
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
|
||||
@@ -487,13 +529,19 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
|
||||
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
|
||||
let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
|
||||
let root_id = number(root, "id_local");
|
||||
let root_path = root_paths.get(&root_id).map_or("", String::as_str);
|
||||
let folder_path = text(folder, "pathFromRoot");
|
||||
let name = text(file, "idx_filename");
|
||||
if root_path.is_empty() || name.is_empty() {
|
||||
return Err(format!("image {id}: incomplete original path"));
|
||||
}
|
||||
let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
|
||||
let (photo_path, original) = original_path(root_path, folder_path, name, path);
|
||||
if let Original::Refused(why) = &original
|
||||
&& !refused_roots.contains(&root_id)
|
||||
{
|
||||
out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
|
||||
}
|
||||
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -504,19 +552,64 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out.photos.push(CatalogPhoto {
|
||||
source_id: id,
|
||||
uuid: text(&image, "id_global").into(),
|
||||
path,
|
||||
path: photo_path,
|
||||
image,
|
||||
settings: develops.get(&id).cloned().unwrap_or_default(),
|
||||
xmp: packet,
|
||||
keywords: keywords.remove(&id).unwrap_or_default(),
|
||||
history: history.remove(&id).unwrap_or_default(),
|
||||
snapshots: snapshots.remove(&id).unwrap_or_default(),
|
||||
original,
|
||||
});
|
||||
}
|
||||
refuse_non_media(&mut out);
|
||||
ensure_read_active(cancel)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
|
||||
/// import's warnings.
|
||||
///
|
||||
/// The catalog decides these paths, and a record it leaves behind is one click from the system
|
||||
/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
|
||||
/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
|
||||
/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
|
||||
/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
|
||||
///
|
||||
/// Called where the catalog is read and again where its photos become records, so neither route
|
||||
/// can keep one; the second pass finds nothing left to say.
|
||||
pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
|
||||
// masters first: a virtual copy may come before its master in the table
|
||||
let refused: HashSet<i64> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
|
||||
.map(|p| p.source_id)
|
||||
.collect();
|
||||
if refused.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
|
||||
// take every master of the catalog with it
|
||||
if master != 0 {
|
||||
if !refused.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
|
||||
return false;
|
||||
}
|
||||
if !refused.contains(&p.source_id) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
|
||||
false
|
||||
});
|
||||
data.warnings.extend(warnings);
|
||||
}
|
||||
|
||||
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -665,11 +758,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
|
||||
let paths: Vec<_> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
|
||||
.map(|p| p.path.clone())
|
||||
.collect();
|
||||
let missing_sources: HashSet<String> =
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
|
||||
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
|
||||
let applied =
|
||||
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
|
||||
@@ -692,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> {
|
||||
pub(crate) undo0: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
|
||||
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
|
||||
refuse_non_media(&mut data);
|
||||
validate(&data).map_err(error)?;
|
||||
let mut warnings = data.warnings.clone();
|
||||
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
|
||||
@@ -713,12 +808,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
let mut photos: Vec<_> = data.photos.iter().collect();
|
||||
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
|
||||
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
|
||||
let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
|
||||
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
|
||||
for src in photos {
|
||||
let master = number(&src.image, "masterImage");
|
||||
if master == 0 && missing_sources.contains(&key(&src.path)) {
|
||||
missing.push(src.path.clone());
|
||||
}
|
||||
// A refused path must not become a library record the app would look at later; its
|
||||
// virtual copies go with it.
|
||||
if refused.contains(&src.source_id) || refused.contains(&master) {
|
||||
continue;
|
||||
}
|
||||
let indexed = index
|
||||
.photos
|
||||
.get(&identity(&data, src))
|
||||
@@ -940,6 +1041,7 @@ mod tests {
|
||||
keywords: vec!["Travel|Goa".into()],
|
||||
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
|
||||
snapshots: Vec::new(),
|
||||
original: Original::Local,
|
||||
};
|
||||
let mut copy = photo.clone();
|
||||
copy.source_id = 2;
|
||||
@@ -962,6 +1064,48 @@ mod tests {
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
|
||||
/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
|
||||
/// copies go with it; the catalog's real photos still import.
|
||||
#[test]
|
||||
fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
|
||||
let mut data = sample();
|
||||
let mut exe = data.photos[1].clone();
|
||||
exe.source_id = 3;
|
||||
exe.uuid = "payload".into();
|
||||
exe.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
|
||||
let mut exe_copy = exe.clone();
|
||||
exe_copy.source_id = 4;
|
||||
exe_copy.uuid = "payload-copy".into();
|
||||
exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
|
||||
// the copy comes before its master, as a catalog's table order may have it
|
||||
data.photos.insert(0, exe_copy);
|
||||
data.photos.push(exe);
|
||||
for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
|
||||
let mut one = data.clone();
|
||||
one.photos.retain(|p| p.source_id < 3);
|
||||
let mut planted = one.photos[1].clone();
|
||||
planted.source_id = 5;
|
||||
planted.uuid = "planted".into();
|
||||
planted.path = bad.into();
|
||||
planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
|
||||
one.photos.push(planted);
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, one, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
|
||||
}
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
|
||||
assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -1508,11 +1652,57 @@ mod tests {
|
||||
// a relative path that leads nowhere keeps the recorded one
|
||||
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_on_other_computers_are_never_looked_at_or_added() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Photos")).unwrap();
|
||||
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
|
||||
let source = dir.join("source.lrcat");
|
||||
let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
|
||||
if cfg!(unix) {
|
||||
// `//<local folder>/` is a local folder to Linux, but a network path to the resolver.
|
||||
roots.push(format!("/{}/", dir.display()));
|
||||
}
|
||||
for root in &roots {
|
||||
std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
|
||||
assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
|
||||
let mut s = crate::Session::new();
|
||||
let probes = Arc::new(AtomicUsize::new(0));
|
||||
let calls = probes.clone();
|
||||
s.media.file_probe = Some(Arc::new(move |_| {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err("a refused original must not be probed".into())
|
||||
}));
|
||||
let report = import(&mut s, &source, false).unwrap();
|
||||
assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
|
||||
assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
|
||||
assert!(s.catalog.is_empty(), "{root}");
|
||||
}
|
||||
// An absolute folder path would replace the root.
|
||||
let mut tables = photo_tables((dir.to_str().unwrap(), ""));
|
||||
tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
|
||||
std::fs::write(&source, native_catalog(&tables)).unwrap();
|
||||
assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
|
||||
// A root beside the catalog is followed.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert_eq!(catalog.photos[0].original, Original::Local);
|
||||
assert!(catalog.photos[0].original_is_file());
|
||||
// A root that moved is found through its path relative to the catalog.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_optional_table_warns_and_is_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if Path::new(&photo.path).is_file() {
|
||||
if photo.original_is_file() {
|
||||
paths.push(photo.path.clone());
|
||||
} else {
|
||||
missing.insert(key);
|
||||
@@ -393,6 +393,7 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
@@ -531,6 +532,7 @@ mod tests {
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 63;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 64;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -2189,6 +2189,40 @@ mod tests {
|
||||
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
|
||||
}
|
||||
|
||||
// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of
|
||||
// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and
|
||||
// at a corner, with tone, colour and detail edits on a camera-sized photo
|
||||
#[test]
|
||||
fn one_to_one_window_pixels_are_export_pixels() {
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow};
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}");
|
||||
let mut d = (*s.develop_of(id).unwrap()).clone();
|
||||
d.light.exposure = 0.7;
|
||||
d.light.contrast = 25.0;
|
||||
d.color.saturation = 20.0;
|
||||
s.set_develop(id, d, "Edits").unwrap();
|
||||
let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image;
|
||||
assert_eq!((export.width, export.height), (w, h), "a full-size export");
|
||||
for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] {
|
||||
let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image;
|
||||
assert_eq!((tile.width, tile.height), (win.w, win.h));
|
||||
let mut worst = 0u8;
|
||||
for y in 0..win.h {
|
||||
for x in 0..win.w {
|
||||
let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]);
|
||||
for k in 0..3 {
|
||||
worst = worst.max(a[k].abs_diff(b[k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
|
||||
/// one nothing can decode) is not read again.
|
||||
#[test]
|
||||
|
||||
@@ -191,6 +191,7 @@ impl MergeJob {
|
||||
metadata: r.metadata,
|
||||
baseline_exposure: r.baseline_exposure,
|
||||
lens: Vec::new(),
|
||||
wb_multipliers: r.wb_multipliers,
|
||||
};
|
||||
f.orient();
|
||||
merged.push(f);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Whether a path the library holds may be handed to the operating system.
|
||||
//!
|
||||
//! A photo's `Source::File` path is not always something a user typed: it can come from an
|
||||
//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
|
||||
//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
|
||||
//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
|
||||
//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
|
||||
//!
|
||||
//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
|
||||
//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
|
||||
//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
|
||||
//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
|
||||
//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
|
||||
//!
|
||||
//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
|
||||
//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
|
||||
//! refused original never becomes a library record in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The absolute path to hand to an opener or external editor, or the reason it is refused.
|
||||
///
|
||||
/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
|
||||
/// the path's own file name.
|
||||
pub fn openable(path: &str, label: &str) -> Result<String, String> {
|
||||
let p = Path::new(path);
|
||||
let name = match label.trim() {
|
||||
"" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
l => l.to_string(),
|
||||
};
|
||||
if !is_media(p) {
|
||||
return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
|
||||
}
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_file() => {}
|
||||
Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
|
||||
Err(e) => return Err(format!("the original is missing: {path} ({e})")),
|
||||
}
|
||||
// a link may name a photo and point at a program; what runs is what it points at
|
||||
if let Ok(target) = p.canonicalize()
|
||||
&& !is_media(&target)
|
||||
{
|
||||
return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
|
||||
}
|
||||
let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
|
||||
Ok(absolute.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
|
||||
pub fn is_media(path: &Path) -> bool {
|
||||
crate::import::is_supported(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, b"x").unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_types_lightcraft_reads_are_handed_over() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
|
||||
let path = write(&dir, good);
|
||||
assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
|
||||
}
|
||||
for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
|
||||
let path = write(&dir, bad);
|
||||
let e = openable(&path, "").unwrap_err();
|
||||
assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
|
||||
}
|
||||
// a bundle (macOS `.app`) and any other folder are not files
|
||||
let bundle = dir.join("Evil.app");
|
||||
std::fs::create_dir_all(&bundle).unwrap();
|
||||
let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
|
||||
let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A relative path is made absolute: the opener runs with its own working directory.
|
||||
#[test]
|
||||
fn the_path_handed_over_is_absolute() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = write(&dir, "a.jpg");
|
||||
let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
|
||||
let out = openable(&dotted.to_string_lossy(), "").unwrap();
|
||||
assert!(Path::new(&out).is_absolute(), "{out}");
|
||||
assert!(out.ends_with("a.jpg"), "{out}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A link named like a photo that points at a program is refused: what the opener runs is
|
||||
/// the target, not the name.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_to_a_program_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = write(&dir, "payload.command");
|
||||
let link = dir.join("holiday.jpg");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let e = openable(&link.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -395,6 +395,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
for (k, v) in fields {
|
||||
let key = format!("crs:{k}");
|
||||
// the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…)
|
||||
if k == "Look"
|
||||
&& let Lua::Table(_, look) = v
|
||||
{
|
||||
for (f, fv) in look {
|
||||
let val = match fv {
|
||||
Lua::Num(n) => format!("{n}"),
|
||||
Lua::Str(s) => delocalize(s),
|
||||
_ => continue,
|
||||
};
|
||||
props.insert(format!("crs:Look/crs:{f}"), vec![val]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
|
||||
values.insert(key.clone(), crate::crs_masks::from_lua(v));
|
||||
}
|
||||
@@ -497,6 +511,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri
|
||||
o.remove("wb");
|
||||
}
|
||||
}
|
||||
// the profile it names, resolved against the user's imported profiles by `preset.import`
|
||||
if let Some(look) = crate::profile_refs::look_ref(props)
|
||||
&& let Some(o) = settings.as_object_mut()
|
||||
{
|
||||
o.insert("profile".into(), serde_json::json!({"look": look}));
|
||||
}
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -684,6 +704,17 @@ text]], z = ZSTR "loc" }"#,
|
||||
assert_eq!(d.light.exposure, 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_keeps_the_profile_it_names() {
|
||||
let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2,
|
||||
Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#;
|
||||
let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap();
|
||||
assert_eq!(
|
||||
v[0].preset.settings["profile"],
|
||||
serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_process_version_fields_are_approximated() {
|
||||
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
||||
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
|
||||
//!
|
||||
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
||||
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
||||
//! background thread with progress and cancellation.
|
||||
//!
|
||||
//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the
|
||||
//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border,
|
||||
//! stroke) are converted to the same encoding.
|
||||
use super::layout::{Layout, Rect, Style};
|
||||
use super::template::PrintSettings;
|
||||
use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster};
|
||||
use crate::Session;
|
||||
use crate::export::{ExportOptions, output_size};
|
||||
use crate::media::RenderJob;
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most photos one print job takes.
|
||||
pub const MAX_PHOTOS: usize = 1000;
|
||||
/// Widest Photo Border, inches.
|
||||
pub const MAX_BORDER_IN: f64 = 2.0;
|
||||
/// Widest Inner Stroke, points.
|
||||
pub const MAX_STROKE_PT: f64 = 36.0;
|
||||
/// Renders kept for cells that print the same photo at the same size.
|
||||
const RENDER_CACHE: usize = 4;
|
||||
|
||||
/// Lightroom's Image Settings panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ImageSettings {
|
||||
/// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo.
|
||||
pub zoom_to_fill: bool,
|
||||
/// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill).
|
||||
pub rotate_to_fit: bool,
|
||||
/// A border around the photo inside its cell; Picture Package and Custom Package only.
|
||||
pub border_enabled: bool,
|
||||
/// Border width, inches.
|
||||
pub border_width: f64,
|
||||
/// Border colour, sRGB.
|
||||
pub border_color: [u8; 3],
|
||||
/// A stroke drawn inside the photo's edge, every layout style.
|
||||
pub stroke_enabled: bool,
|
||||
/// Stroke width, points.
|
||||
pub stroke_width_pt: f64,
|
||||
/// Stroke colour, sRGB.
|
||||
pub stroke_color: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for ImageSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zoom_to_fill: false,
|
||||
rotate_to_fit: false,
|
||||
border_enabled: false,
|
||||
border_width: 0.25,
|
||||
border_color: [255; 3],
|
||||
stroke_enabled: false,
|
||||
stroke_width_pt: 1.0,
|
||||
stroke_color: [0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageSettings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v);
|
||||
if !ok(self.border_width, MAX_BORDER_IN) {
|
||||
return Err(format!("border width must be 0 to {MAX_BORDER_IN} in"));
|
||||
}
|
||||
if !ok(self.stroke_width_pt, MAX_STROKE_PT) {
|
||||
return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightroom's Page panel (the parts that are not margins or overlays).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PageStyle {
|
||||
/// Page background colour, sRGB.
|
||||
pub background: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for PageStyle {
|
||||
fn default() -> Self {
|
||||
Self { background: [255; 3] }
|
||||
}
|
||||
}
|
||||
|
||||
/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]).
|
||||
pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
|
||||
let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0)));
|
||||
let trc = OutputSpace::Rec2020.trc();
|
||||
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
||||
}
|
||||
|
||||
// ---- placement ------------------------------------------------------------------------------
|
||||
|
||||
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placement {
|
||||
/// Turned a quarter (clockwise).
|
||||
pub rotated: bool,
|
||||
/// Scaled size after turning: at most the area when fitting, at least the area when filling.
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// The scaled size before turning (the size to render at).
|
||||
pub fn unturned(&self) -> (usize, usize) {
|
||||
if self.rotated { (self.h, self.w) } else { (self.w, self.h) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides turning and scale. `None` for an empty area or a photo without size.
|
||||
pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option<Placement> {
|
||||
if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 {
|
||||
return None;
|
||||
}
|
||||
let (af, bf) = (aw as f64, ah as f64);
|
||||
let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) };
|
||||
let (s0, s1) = (scale(iw, ih), scale(ih, iw));
|
||||
let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) };
|
||||
let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) };
|
||||
let (w, h) = if fill {
|
||||
(((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah))
|
||||
} else {
|
||||
((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah))
|
||||
};
|
||||
Some(Placement { rotated, w, h })
|
||||
}
|
||||
|
||||
/// A rectangle in page pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PxRect {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page.
|
||||
pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option<PxRect> {
|
||||
if !r.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize;
|
||||
let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph));
|
||||
(x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 })
|
||||
}
|
||||
|
||||
/// The cells of every page with the photo each prints, by layout style: Single Image and Contact
|
||||
/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the
|
||||
/// first photo in every cell; Custom Package prints each cell's own photo (else the first).
|
||||
pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result<Vec<Vec<(Rect, PhotoId)>>, String> {
|
||||
let first = photos.first().copied().ok_or("select at least one photo to print")?;
|
||||
if photos.len() > MAX_PHOTOS {
|
||||
return Err(format!("a print takes at most {MAX_PHOTOS} photos"));
|
||||
}
|
||||
match layout.style {
|
||||
Style::SingleImage | Style::ContactSheet => {
|
||||
let grid = layout.grid_rects()?;
|
||||
let per_page = grid.len().max(1);
|
||||
let pages = photos.len().div_ceil(per_page);
|
||||
if pages > MAX_JOB_PAGES {
|
||||
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos"));
|
||||
}
|
||||
Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect())
|
||||
}
|
||||
Style::PicturePackage | Style::CustomPackage => {
|
||||
let pages = layout.pages.max(1) as usize;
|
||||
if pages > MAX_JOB_PAGES {
|
||||
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages"));
|
||||
}
|
||||
if layout.cells.is_empty() {
|
||||
return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into());
|
||||
}
|
||||
let mut out: Vec<Vec<(Rect, PhotoId)>> = vec![Vec::new(); pages];
|
||||
for c in &layout.cells {
|
||||
let photo = match layout.style {
|
||||
Style::CustomPackage => c.photo.map_or(first, PhotoId),
|
||||
_ => first,
|
||||
};
|
||||
out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pixels ----------------------------------------------------------------------------------
|
||||
|
||||
/// 16-bit interleaved RGB.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image16 {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Image16 {
|
||||
pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> {
|
||||
let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?;
|
||||
let mut rgb = Vec::new();
|
||||
rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?;
|
||||
for _ in 0..w * h {
|
||||
rgb.extend_from_slice(&c);
|
||||
}
|
||||
Ok(Self { w, h, rgb })
|
||||
}
|
||||
|
||||
fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> {
|
||||
let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?;
|
||||
let s = self.rgb.get(i..i.checked_add(3)?)?;
|
||||
Some([*s.first()?, *s.get(1)?, *s.get(2)?])
|
||||
}
|
||||
|
||||
/// Fills a rectangle (clipped to the image).
|
||||
pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) {
|
||||
let x1 = x.saturating_add(w).min(self.w);
|
||||
for row in y..y.saturating_add(h).min(self.h) {
|
||||
let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue };
|
||||
for px in line.as_chunks_mut::<3>().0 {
|
||||
*px = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images.
|
||||
pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) {
|
||||
let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
|
||||
let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
|
||||
for row in 0..h {
|
||||
let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3);
|
||||
let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3);
|
||||
if let (Some(from), Some(to)) = (from, to) {
|
||||
to.copy_from_slice(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quarter turn clockwise.
|
||||
pub fn rotated_cw(&self) -> Result<Image16, String> {
|
||||
let mut out = Image16::filled(self.h, self.w, [0; 3])?;
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let Some(p) = self.px(x, y) else { continue };
|
||||
// (x, y) lands at (h - 1 - y, x).
|
||||
let i = (x * out.w + (self.h - 1 - y)) * 3;
|
||||
if let Some(d) = out.rgb.get_mut(i..i + 3) {
|
||||
d.copy_from_slice(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it.
|
||||
pub fn resized(&self, nw: usize, nh: usize) -> Result<Image16, String> {
|
||||
if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 {
|
||||
return Err("empty image".into());
|
||||
}
|
||||
if (nw, nh) == (self.w, self.h) {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
let wx = axis_weights(self.w, nw);
|
||||
let wy = axis_weights(self.h, nh);
|
||||
// Horizontal pass into 16-bit rows, then the vertical pass.
|
||||
let mut mid = Image16::filled(nw, self.h, [0; 3])?;
|
||||
for y in 0..self.h {
|
||||
let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue };
|
||||
let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue };
|
||||
for (x, taps) in wx.iter().enumerate() {
|
||||
for c in 0..3 {
|
||||
let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum();
|
||||
if let Some(d) = dst.get_mut(x * 3 + c) {
|
||||
*d = v.round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Image16::filled(nw, nh, [0; 3])?;
|
||||
for (y, taps) in wy.iter().enumerate() {
|
||||
let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue };
|
||||
for (i, d) in dst.iter_mut().enumerate() {
|
||||
let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum();
|
||||
*d = v.round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1).
|
||||
fn axis_weights(old: usize, new: usize) -> Vec<Vec<(usize, f32)>> {
|
||||
let (o, n) = (old as f64, new as f64);
|
||||
(0..new)
|
||||
.map(|i| {
|
||||
if new < old {
|
||||
let step = o / n;
|
||||
let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step);
|
||||
let first = a.floor() as usize;
|
||||
let last = (b.ceil() as usize).min(old);
|
||||
(first..last)
|
||||
.filter_map(|j| {
|
||||
let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0);
|
||||
(overlap > 0.0).then_some((j, (overlap / step) as f32))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let pos = (i as f64 + 0.5) * o / n - 0.5;
|
||||
let j0 = pos.floor();
|
||||
let f = (pos - j0) as f32;
|
||||
let at = |j: f64| j.clamp(0.0, o - 1.0) as usize;
|
||||
vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything about a cell's look that does not depend on the photo.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CellStyle {
|
||||
pub fill: bool,
|
||||
pub rotate: bool,
|
||||
pub border_px: usize,
|
||||
pub border: [u16; 3],
|
||||
pub stroke_px: usize,
|
||||
pub stroke: [u16; 3],
|
||||
}
|
||||
|
||||
impl CellStyle {
|
||||
/// The style for `settings` at `ppi`; the border applies to the package styles only.
|
||||
pub fn of(settings: &PrintSettings, ppi: f64) -> Self {
|
||||
let img = &settings.image;
|
||||
let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 };
|
||||
let stroke_px =
|
||||
if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 };
|
||||
Self {
|
||||
fill: img.zoom_to_fill,
|
||||
rotate: img.rotate_to_fit,
|
||||
border_px,
|
||||
border: rec2020_from_srgb8(img.border_color),
|
||||
stroke_px,
|
||||
stroke: rec2020_from_srgb8(img.stroke_color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
||||
/// asked), then the inner stroke on top of it.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
|
||||
// The border may not eat the cell: at least one pixel of photo area stays.
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (ax, ay) = (cell.x + border, cell.y + border);
|
||||
let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border);
|
||||
let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?;
|
||||
let (uw, uh) = p.unturned();
|
||||
let mut img = photo.resized(uw, uh)?;
|
||||
if p.rotated {
|
||||
img = img.rotated_cw()?;
|
||||
}
|
||||
// Where the photo lands: centred in the area, cropped to it when it overflows.
|
||||
let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2);
|
||||
let (w, h) = (img.w.min(aw), img.h.min(ah));
|
||||
let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2);
|
||||
if border > 0 {
|
||||
page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border);
|
||||
}
|
||||
page.blit(&img, sx, sy, dx, dy, w, h);
|
||||
let s = style.stroke_px.min(w.min(h) / 2);
|
||||
if s > 0 {
|
||||
page.fill_rect(dx, dy, w, s, style.stroke);
|
||||
page.fill_rect(dx, dy + h - s, w, s, style.stroke);
|
||||
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
||||
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- the job ---------------------------------------------------------------------------------
|
||||
|
||||
struct Item {
|
||||
page: usize,
|
||||
name: String,
|
||||
cell: PxRect,
|
||||
key: (u64, usize, usize),
|
||||
job: RenderJob,
|
||||
}
|
||||
|
||||
/// A print job set up from the session: one render per cell, ready to run on another thread.
|
||||
pub struct Prepared {
|
||||
pages: Vec<(usize, usize)>,
|
||||
items: Vec<Item>,
|
||||
ppi: f32,
|
||||
background: [u16; 3],
|
||||
style: CellStyle,
|
||||
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
||||
color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Renders (progress steps) the job will run.
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Pages the job will produce.
|
||||
pub fn pages(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
|
||||
/// Renders every photo and composes the pages. `progress(done, name)` is called before each
|
||||
/// render with the photo's file name; returning `false` cancels (no partial result).
|
||||
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Vec<PageRaster>, String> {
|
||||
let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new();
|
||||
let mut done = 0usize;
|
||||
let mut items = self.items.into_iter().peekable();
|
||||
let mut out = Vec::with_capacity(self.pages.len());
|
||||
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
||||
let mut page = Image16::filled(w, h, self.background)?;
|
||||
while let Some(item) = items.next_if(|i| i.page == index) {
|
||||
if !progress(done, &item.name) {
|
||||
return Err("Print cancelled".into());
|
||||
}
|
||||
done += 1;
|
||||
let at = cache.iter().position(|(k, _)| *k == item.key);
|
||||
let at = match at {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
if cache.len() >= RENDER_CACHE {
|
||||
cache.remove(0);
|
||||
}
|
||||
cache.push((item.key, img));
|
||||
cache.len() - 1
|
||||
}
|
||||
};
|
||||
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
||||
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
}
|
||||
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
||||
}
|
||||
if !progress(done, "") {
|
||||
return Err("Print cancelled".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result<Image16, String> {
|
||||
let rendered = job.run().rendered?;
|
||||
let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?;
|
||||
// print sharpening for the paper, at the size the photo prints
|
||||
color.sharpen_photo(&mut deep);
|
||||
let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) };
|
||||
if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) {
|
||||
return Err("the render is not Rec. 2020 RGB".into());
|
||||
}
|
||||
Ok(Image16 { w: deep.width, h: deep.height, rgb })
|
||||
}
|
||||
|
||||
/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell.
|
||||
pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result<Prepared, String> {
|
||||
let mut settings = settings.clone();
|
||||
settings.validate()?;
|
||||
let ppi = f64::from(settings.job.effective_ppi());
|
||||
let (pw_in, ph_in) = settings.layout.page_size()?;
|
||||
let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize);
|
||||
let page_px = (pw as u64).saturating_mul(ph as u64);
|
||||
if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS {
|
||||
return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution"));
|
||||
}
|
||||
let plan = plan_cells(&settings.layout, photos)?;
|
||||
if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS {
|
||||
return Err("the print is too large for memory; lower the print resolution or print fewer pages".into());
|
||||
}
|
||||
let style = CellStyle::of(&settings, ppi);
|
||||
let mut items = Vec::new();
|
||||
for (page, cells) in plan.iter().enumerate() {
|
||||
for (rect, id) in cells {
|
||||
let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue };
|
||||
let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?;
|
||||
let name = photo.file_name.clone();
|
||||
let (nw, nh) = output_size(photo, &ExportOptions::default());
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border);
|
||||
let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?;
|
||||
let (rw, rh) = p.unturned();
|
||||
let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?;
|
||||
items.push(Item { page, name, cell, key: (id.0, rw, rh), job });
|
||||
}
|
||||
}
|
||||
Ok(Prepared {
|
||||
pages: vec![(pw, ph); plan.len()],
|
||||
items,
|
||||
ppi: ppi as f32,
|
||||
background: rec2020_from_srgb8(settings.page.background),
|
||||
style,
|
||||
color: settings.job.color.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::print::layout::{GridSpec, Margins};
|
||||
|
||||
const RED: [u16; 3] = [60000, 1000, 1000];
|
||||
const BLUE: [u16; 3] = [1000, 1000, 60000];
|
||||
const WHITE: [u16; 3] = [65535; 3];
|
||||
|
||||
/// A `w` x `h` image whose left half is red and right half blue, with a green top row.
|
||||
fn halves(w: usize, h: usize) -> Image16 {
|
||||
let mut img = Image16::filled(w, h, RED).unwrap();
|
||||
img.fill_rect(w / 2, 0, w - w / 2, h, BLUE);
|
||||
img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]);
|
||||
img
|
||||
}
|
||||
|
||||
fn style() -> CellStyle {
|
||||
CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] }
|
||||
}
|
||||
|
||||
fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] {
|
||||
img.px(x, y).unwrap()
|
||||
}
|
||||
|
||||
fn page() -> Image16 {
|
||||
Image16::filled(100, 100, WHITE).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_fit_fill_and_rotation() {
|
||||
// 4:3 photo into a 2:1 area.
|
||||
let fit = place(400.0, 300.0, 200, 100, false, false).unwrap();
|
||||
assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false));
|
||||
let fill = place(400.0, 300.0, 200, 100, true, false).unwrap();
|
||||
assert_eq!((fill.w, fill.h), (200, 150));
|
||||
// A portrait photo in a landscape area: turning prints it larger.
|
||||
assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 });
|
||||
let turned = place(300.0, 400.0, 400, 300, false, true).unwrap();
|
||||
assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300));
|
||||
assert_eq!(turned.unturned(), (300, 400));
|
||||
// Filling a landscape cell with a portrait photo crops less when turned.
|
||||
let f = place(300.0, 400.0, 400, 300, true, true).unwrap();
|
||||
assert!(f.rotated && f.w >= 400 && f.h >= 300);
|
||||
// Matching orientation never turns.
|
||||
assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated);
|
||||
assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated);
|
||||
// Hostile input.
|
||||
for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] {
|
||||
assert!(place(bad, 10.0, 10, 10, false, false).is_none());
|
||||
assert!(place(10.0, bad, 10, 10, true, true).is_none());
|
||||
}
|
||||
assert!(place(10.0, 10.0, 0, 10, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_centres_the_photo() {
|
||||
let mut p = page();
|
||||
draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap();
|
||||
// 40x20 scaled to 80x40, centred vertically at y 30..70.
|
||||
assert_eq!(at(&p, 10, 50), RED);
|
||||
assert_eq!(at(&p, 89, 50), BLUE);
|
||||
assert_eq!(at(&p, 50, 20), WHITE, "above the photo");
|
||||
assert_eq!(at(&p, 50, 75), WHITE, "below the photo");
|
||||
assert_eq!(at(&p, 5, 50), WHITE, "outside the cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fill_crops_the_centre() {
|
||||
let mut p = page();
|
||||
let s = CellStyle { fill: true, ..style() };
|
||||
draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap();
|
||||
// The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell.
|
||||
for y in [10, 50, 89] {
|
||||
assert_ne!(at(&p, 10, y), WHITE);
|
||||
assert_ne!(at(&p, 89, y), WHITE);
|
||||
}
|
||||
assert_eq!(at(&p, 20, 50), RED);
|
||||
assert_eq!(at(&p, 80, 50), BLUE);
|
||||
// Nothing leaks outside the cell.
|
||||
assert_eq!(at(&p, 9, 50), WHITE);
|
||||
assert_eq!(at(&p, 90, 50), WHITE);
|
||||
assert_eq!(at(&p, 50, 9), WHITE);
|
||||
assert_eq!(at(&p, 50, 90), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_to_fit_turns_clockwise() {
|
||||
// A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell.
|
||||
let mut p = page();
|
||||
let cell = PxRect { x: 10, y: 10, w: 80, h: 40 };
|
||||
draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap();
|
||||
assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle");
|
||||
assert_eq!(at(&p, 45, 30), RED);
|
||||
let mut q = page();
|
||||
draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap();
|
||||
// Turned clockwise the left half (red) becomes the top half and the photo spans the cell.
|
||||
assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12));
|
||||
assert_eq!(at(&q, 12, 48), BLUE);
|
||||
assert_eq!(at(&q, 50, 20), RED);
|
||||
assert_eq!(at(&q, 50, 40), BLUE);
|
||||
assert_eq!(at(&q, 11, 30), at(&q, 11, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_frames_the_photo_inside_the_cell() {
|
||||
let mut p = page();
|
||||
let green = [100, 60000, 100];
|
||||
let s = CellStyle { border_px: 5, border: green, fill: true, ..style() };
|
||||
draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap();
|
||||
assert_eq!(at(&p, 10, 40), green);
|
||||
assert_eq!(at(&p, 14, 40), green);
|
||||
assert_eq!(at(&p, 15, 40), RED);
|
||||
assert_eq!(at(&p, 69, 40), green);
|
||||
assert_eq!(at(&p, 64, 40), BLUE);
|
||||
assert_eq!(at(&p, 40, 10), green);
|
||||
assert_eq!(at(&p, 40, 69), green);
|
||||
// A fitted photo gets the border hugging it, not the whole cell.
|
||||
let mut q = page();
|
||||
let s = CellStyle { border_px: 5, border: green, ..style() };
|
||||
draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap();
|
||||
// The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick.
|
||||
assert_eq!(at(&q, 50, 23), green);
|
||||
assert_eq!(at(&q, 50, 22), green);
|
||||
assert_eq!(at(&q, 50, 21), WHITE);
|
||||
// A border wider than the cell leaves the photo visible.
|
||||
let mut r = page();
|
||||
let s = CellStyle { border_px: 500, border: green, ..style() };
|
||||
draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap();
|
||||
assert_ne!(at(&r, 20, 20), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_is_drawn_inside_the_photo_edge() {
|
||||
let mut p = page();
|
||||
let black = [0, 0, 0];
|
||||
let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() };
|
||||
draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap();
|
||||
for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] {
|
||||
assert_eq!(at(&p, x, y), black, "({x}, {y})");
|
||||
}
|
||||
assert_eq!(at(&p, 23, 40), RED);
|
||||
assert_eq!(at(&p, 76, 40), BLUE);
|
||||
assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo");
|
||||
// Corners are covered exactly once and a huge stroke stays within the photo.
|
||||
let mut q = page();
|
||||
let s = CellStyle { stroke_px: 1000, stroke: black, ..style() };
|
||||
draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap();
|
||||
assert_eq!(at(&q, 40, 40), black);
|
||||
assert_eq!(at(&q, 19, 40), WHITE);
|
||||
assert_eq!(at(&q, 60, 40), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_are_rec2020_encoded() {
|
||||
assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]);
|
||||
assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]);
|
||||
let grey = rec2020_from_srgb8([128; 3]);
|
||||
assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3);
|
||||
// sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero.
|
||||
let red = rec2020_from_srgb8([255, 0, 0]);
|
||||
assert!(red[0] < 65535 && red[1] > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_preserves_flat_colour_and_edges() {
|
||||
let img = halves(64, 32);
|
||||
for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] {
|
||||
let r = img.resized(w, h).unwrap();
|
||||
assert_eq!((r.w, r.h), (w, h));
|
||||
assert_eq!(r.rgb.len(), w * h * 3);
|
||||
}
|
||||
let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap();
|
||||
assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999]));
|
||||
assert!(img.resized(0, 5).is_err());
|
||||
let r = img.resized(32, 16).unwrap();
|
||||
assert_eq!(at(&r, 4, 8), RED);
|
||||
assert_eq!(at(&r, 28, 8), BLUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_and_blit_are_clipped() {
|
||||
let img = halves(3, 2);
|
||||
let r = img.rotated_cw().unwrap();
|
||||
assert_eq!((r.w, r.h), (2, 3));
|
||||
let mut p = page();
|
||||
p.blit(&img, 0, 0, 99, 99, 10, 10);
|
||||
assert_eq!(at(&p, 99, 99), at(&img, 0, 0));
|
||||
p.blit(&img, 5, 5, 0, 0, 3, 3);
|
||||
p.fill_rect(95, 95, 50, 50, RED);
|
||||
assert_eq!(at(&p, 99, 99), RED);
|
||||
p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED);
|
||||
}
|
||||
|
||||
fn settings(style: Style) -> PrintSettings {
|
||||
let mut s = PrintSettings::default();
|
||||
s.layout.style = style;
|
||||
s.layout.page.paper = "4x6".into();
|
||||
s.layout.page.margins = Margins::uniform(0.5);
|
||||
s.job.ppi = 100.0;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_follow_the_layout_style() {
|
||||
let ids: Vec<PhotoId> = (1..=5).map(PhotoId).collect();
|
||||
let mut s = settings(Style::ContactSheet);
|
||||
s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() };
|
||||
let plan = plan_cells(&s.layout, &ids).unwrap();
|
||||
assert_eq!(plan.iter().map(Vec::len).collect::<Vec<_>>(), vec![4, 1]);
|
||||
assert_eq!(plan[1][0].1, PhotoId(5));
|
||||
let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap();
|
||||
assert_eq!(one.len(), 5);
|
||||
assert!(plan_cells(&s.layout, &[]).is_err());
|
||||
assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err());
|
||||
|
||||
let mut pkg = settings(Style::PicturePackage);
|
||||
assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet");
|
||||
pkg.layout.add_cell(1.0, 1.0).unwrap();
|
||||
pkg.layout.add_cell(2.0, 2.0).unwrap();
|
||||
let plan = plan_cells(&pkg.layout, &ids).unwrap();
|
||||
assert_eq!(plan.len(), 1);
|
||||
assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo");
|
||||
|
||||
let mut custom = settings(Style::CustomPackage);
|
||||
let a = custom.layout.add_cell(1.0, 1.0).unwrap();
|
||||
custom.layout.add_cell(1.0, 1.0).unwrap();
|
||||
custom.layout.set_cell_photo(a, Some(3)).unwrap();
|
||||
let plan = plan_cells(&custom.layout, &ids).unwrap();
|
||||
assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1)));
|
||||
let blank_page = {
|
||||
custom.layout.new_page().unwrap();
|
||||
plan_cells(&custom.layout, &ids).unwrap()
|
||||
};
|
||||
assert_eq!(blank_page.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 0]);
|
||||
}
|
||||
|
||||
fn first_ids(s: &Session, n: usize) -> Vec<PhotoId> {
|
||||
s.catalog.photos().take(n).map(|p| p.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composes_pages_from_real_photos() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = first_ids(&s, 3);
|
||||
let mut set = settings(Style::ContactSheet);
|
||||
set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false };
|
||||
set.page.background = [200, 30, 30];
|
||||
set.image.zoom_to_fill = true;
|
||||
set.image.stroke_enabled = true;
|
||||
set.image.stroke_width_pt = 2.0;
|
||||
set.image.stroke_color = [0, 0, 0];
|
||||
let work = prepare(&mut s, &set, &ids).unwrap();
|
||||
assert_eq!((work.len(), work.pages()), (3, 2));
|
||||
let mut steps = Vec::new();
|
||||
let pages = work
|
||||
.run(&mut |i, name| {
|
||||
steps.push((i, name.to_string()));
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(steps.iter().map(|s| s.0).collect::<Vec<_>>(), vec![0, 1, 2, 3]);
|
||||
assert_eq!(pages.len(), 2);
|
||||
let first = &pages[0];
|
||||
assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0));
|
||||
assert_eq!(first.rgb.len(), 400 * 600 * 3);
|
||||
let bg = rec2020_from_srgb8([200, 30, 30]);
|
||||
let px = |p: &PageRaster, x: usize, y: usize| {
|
||||
let i = (y * p.width as usize + x) * 3;
|
||||
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
|
||||
};
|
||||
assert_eq!(px(first, 5, 5), bg, "margin shows the page background");
|
||||
assert_eq!(px(first, 395, 595), bg);
|
||||
// Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour.
|
||||
assert_eq!(px(first, 50, 50), [0, 0, 0]);
|
||||
let centre = px(first, 200, 150);
|
||||
assert_ne!(centre, bg);
|
||||
assert_ne!(centre, [0, 0, 0]);
|
||||
// The second page holds the third photo in the first cell only: the second cell is bare.
|
||||
let second = &pages[1];
|
||||
assert_ne!(px(second, 200, 150), bg);
|
||||
assert_eq!(px(second, 200, 450), bg);
|
||||
// Cancelling gives no pages.
|
||||
let work = prepare(&mut s, &set, &ids).unwrap();
|
||||
assert!(work.run(&mut |i, _| i < 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_renders_each_size_and_border() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = first_ids(&s, 1);
|
||||
let mut set = settings(Style::PicturePackage);
|
||||
set.layout.add_cell(1.5, 1.0).unwrap();
|
||||
set.layout.add_cell(1.5, 1.0).unwrap();
|
||||
set.image.border_enabled = true;
|
||||
set.image.border_width = 0.1;
|
||||
set.image.border_color = [10, 200, 10];
|
||||
let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap();
|
||||
assert_eq!(pages.len(), 1);
|
||||
let green = rec2020_from_srgb8([10, 200, 10]);
|
||||
let cell = set.layout.cells[0].rect;
|
||||
let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize);
|
||||
let at = |x: usize, y: usize| {
|
||||
let i = (y * 400 + x) * 3;
|
||||
pages[0].rgb[i..i + 3].to_vec()
|
||||
};
|
||||
// The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round.
|
||||
let mid = (x0 + 75, y0 + 50);
|
||||
assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle");
|
||||
let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count();
|
||||
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_and_hostile_settings_are_refused() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = first_ids(&s, 1);
|
||||
let mut set = settings(Style::SingleImage);
|
||||
set.layout.page.paper = "custom".into();
|
||||
set.layout.page.custom_width = 60.0;
|
||||
set.layout.page.custom_height = 60.0;
|
||||
set.job.ppi = 720.0;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap");
|
||||
let mut set = settings(Style::SingleImage);
|
||||
set.job.ppi = f32::NAN;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err());
|
||||
let mut set = settings(Style::SingleImage);
|
||||
set.image.border_width = f64::NAN;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err());
|
||||
set.image.border_width = 0.1;
|
||||
set.image.stroke_width_pt = -1.0;
|
||||
assert!(prepare(&mut s, &set, &ids).is_err());
|
||||
assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err());
|
||||
assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err());
|
||||
let set = settings(Style::SingleImage);
|
||||
assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages");
|
||||
// 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap.
|
||||
let mut big = settings(Style::SingleImage);
|
||||
big.job.ppi = 720.0;
|
||||
assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
//! The Print module: page layout, composition of pages and PDF output.
|
||||
//!
|
||||
//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its
|
||||
//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages
|
||||
//! as a PDF.
|
||||
pub mod compose;
|
||||
pub mod layout;
|
||||
pub mod output;
|
||||
pub mod template;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lowest and highest print resolution, pixels per inch.
|
||||
pub const MIN_PPI: f32 = 72.0;
|
||||
pub const MAX_PPI: f32 = 720.0;
|
||||
/// Pages a single print may have.
|
||||
pub const MAX_JOB_PAGES: usize = 100;
|
||||
/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60).
|
||||
pub const MAX_PAGE_PIXELS: u64 = 100_000_000;
|
||||
/// Most pixels a print job may hold across all its pages (6 bytes each in memory).
|
||||
pub const MAX_JOB_PIXELS: u64 = 200_000_000;
|
||||
|
||||
/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in
|
||||
/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's).
|
||||
/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PageRaster {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub ppi: f32,
|
||||
pub rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour
|
||||
/// management, print adjustment, print sharpening and depth of [`crate::print_color`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PrintJob {
|
||||
/// Print resolution in pixels per inch, 72 to 720.
|
||||
pub ppi: f32,
|
||||
/// Profile, rendering intent, black point compensation, print adjustment, print sharpening.
|
||||
pub color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
|
||||
impl Default for PrintJob {
|
||||
fn default() -> Self {
|
||||
Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintJob {
|
||||
/// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN).
|
||||
pub fn effective_ppi(&self) -> f32 {
|
||||
if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 }
|
||||
}
|
||||
|
||||
/// Rejects a resolution outside the supported range (commands and templates, which should
|
||||
/// say so rather than silently clamp).
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) {
|
||||
return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}"));
|
||||
}
|
||||
self.color.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ppi_is_clamped_and_validated() {
|
||||
assert_eq!(PrintJob::default().effective_ppi(), 240.0);
|
||||
for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] {
|
||||
assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e);
|
||||
}
|
||||
assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok());
|
||||
for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] {
|
||||
assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err());
|
||||
}
|
||||
let j: PrintJob = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(j, PrintJob::default());
|
||||
assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err());
|
||||
// the colour settings ride along, defaults filled in, and are validated with the job
|
||||
let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap();
|
||||
assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative);
|
||||
assert!(j.color.black_point_compensation && j.validate().is_ok());
|
||||
let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap();
|
||||
assert!(j.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target
|
||||
//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its
|
||||
//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded
|
||||
//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer
|
||||
//! profile is also named as the document's output intent ([`crate::print_pdf`]).
|
||||
use super::{MAX_JOB_PAGES, PageRaster, PrintJob};
|
||||
use crate::print_color::PrintColorJob;
|
||||
use crate::print_pdf::ImageData;
|
||||
use lightcraft_color::transfer::encode_srgb8;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_pipeline::OutputSpace;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
/// Largest PDF produced, bytes.
|
||||
const MAX_PDF_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped).
|
||||
pub fn page_to_srgb8(page: &PageRaster) -> Result<Rgba8, String> {
|
||||
let (w, h) = (page.width as usize, page.height as usize);
|
||||
let n = w.checked_mul(h).ok_or("page too large")?;
|
||||
if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? {
|
||||
return Err("a page's pixel data does not match its size".into());
|
||||
}
|
||||
let trc = OutputSpace::Rec2020.trc();
|
||||
// 16-bit Rec. 2020 code → linear light, once per code value.
|
||||
let lut: Vec<f32> = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect();
|
||||
let m = REC2020.to_space(&SRGB).to_f32();
|
||||
let mut out = Rgba8::new(w, h);
|
||||
for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) {
|
||||
let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0));
|
||||
let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]);
|
||||
*dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255];
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in
|
||||
/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed
|
||||
/// in turn, so memory holds one page's device samples at a time.
|
||||
pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8>, String> {
|
||||
let first = pages.first().ok_or("nothing to print")?;
|
||||
if pages.len() > MAX_JOB_PAGES {
|
||||
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages"));
|
||||
}
|
||||
if !(first.ppi.is_finite() && first.ppi > 0.0) {
|
||||
return Err("a page has no resolution".into());
|
||||
}
|
||||
let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0;
|
||||
let size = (points(first.width), points(first.height));
|
||||
let color = PrintColorJob::new(&job.color)?;
|
||||
let mut out = Vec::with_capacity(pages.len());
|
||||
let mut bytes = 0usize;
|
||||
for page in pages {
|
||||
if (page.width, page.height) != (first.width, first.height) {
|
||||
return Err("all pages of a PDF must be the same size".into());
|
||||
}
|
||||
let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?;
|
||||
if let ImageData::Flate { data, .. } = &image.data {
|
||||
bytes = bytes.saturating_add(data.len());
|
||||
}
|
||||
if bytes > MAX_PDF_BYTES {
|
||||
return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into());
|
||||
}
|
||||
out.push((size, image));
|
||||
}
|
||||
color.pdf(out, title)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::print::compose::rec2020_from_srgb8;
|
||||
|
||||
fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster {
|
||||
let c = rec2020_from_srgb8(srgb);
|
||||
PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_survive_the_round_trip() {
|
||||
for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] {
|
||||
let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0];
|
||||
for k in 0..3 {
|
||||
assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}");
|
||||
}
|
||||
}
|
||||
assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err());
|
||||
assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_pages_have_the_paper_size() {
|
||||
let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])];
|
||||
let bytes = write(&pages, &PrintJob::default(), "t").unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.starts_with("%PDF-"));
|
||||
assert!(text.contains("/Count 2"));
|
||||
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
|
||||
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt");
|
||||
assert!(text.contains("/Width 425 /Height 550"));
|
||||
// managed by the printer: sRGB, 16 bits, tagged, no output intent
|
||||
assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents"));
|
||||
assert!(write(&[], &PrintJob::default(), "t").is_err());
|
||||
let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])];
|
||||
assert!(write(&mixed, &PrintJob::default(), "t").is_err());
|
||||
let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) };
|
||||
assert!(write(&[bad], &PrintJob::default(), "t").is_err());
|
||||
let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) };
|
||||
assert!(write(&[zero], &PrintJob::default(), "t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() {
|
||||
use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile};
|
||||
let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let icc = dir.join("Lustre Paper.icc");
|
||||
std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap();
|
||||
let color =
|
||||
PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() };
|
||||
let job = PrintJob { color, ..Default::default() };
|
||||
let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]);
|
||||
assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)"));
|
||||
// a profile that isn't one fails the print and says which file
|
||||
std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap();
|
||||
let junk = PrintJob {
|
||||
color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err();
|
||||
assert!(err.contains("Junk"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! The settings a print is made from, and (with the saved-template commands) what a print
|
||||
//! template stores.
|
||||
use super::PrintJob;
|
||||
use super::compose::{ImageSettings, PageStyle};
|
||||
use super::layout::{Guides, Layout};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Layout, image settings, page, guides and job: the whole Print module state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PrintSettings {
|
||||
pub layout: Layout,
|
||||
pub image: ImageSettings,
|
||||
pub page: PageStyle,
|
||||
pub guides: Guides,
|
||||
pub job: PrintJob,
|
||||
}
|
||||
|
||||
impl PrintSettings {
|
||||
/// Checks every part; run on anything that did not come from the setters.
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
self.layout.validate()?;
|
||||
self.image.validate()?;
|
||||
self.guides.validate()?;
|
||||
self.job.validate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest template name, characters.
|
||||
pub const MAX_NAME: usize = 80;
|
||||
/// Most templates kept.
|
||||
pub const MAX_TEMPLATES: usize = 200;
|
||||
|
||||
/// A saved print template: the layout, image settings, page and job of a print, without the
|
||||
/// photos assigned to cells and without the guides (a view preference). The settings are kept as
|
||||
/// JSON so a template written by another version never makes the preferences file unreadable;
|
||||
/// [`PrintTemplate::settings`] checks them when they are used.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrintTemplate {
|
||||
pub name: String,
|
||||
pub settings: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters.
|
||||
pub fn clean_name(name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) {
|
||||
return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters"));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
impl PrintTemplate {
|
||||
/// A template of `settings` (validated first) named `name`.
|
||||
pub fn new(name: &str, settings: &PrintSettings) -> Result<Self, String> {
|
||||
let mut s = settings.clone();
|
||||
s.validate()?;
|
||||
for c in &mut s.layout.cells {
|
||||
c.photo = None;
|
||||
}
|
||||
let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?;
|
||||
if let Some(o) = value.as_object_mut() {
|
||||
o.remove("guides");
|
||||
}
|
||||
Ok(Self { name: clean_name(name)?, settings: value })
|
||||
}
|
||||
|
||||
/// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors.
|
||||
/// `guides` are those of the print the template is applied to.
|
||||
pub fn settings(&self, guides: &Guides) -> Result<PrintSettings, String> {
|
||||
let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?;
|
||||
s.guides = *guides;
|
||||
s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::print::layout::Style;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_template_keeps_the_look_not_the_photos_or_guides() {
|
||||
let mut s = PrintSettings::default();
|
||||
s.layout.style = Style::CustomPackage;
|
||||
let id = s.layout.add_cell(3.0, 2.0).unwrap();
|
||||
s.layout.set_cell_photo(id, Some(77)).unwrap();
|
||||
s.image.zoom_to_fill = true;
|
||||
s.page.background = [10, 20, 30];
|
||||
s.job.ppi = 300.0;
|
||||
s.guides.grid_size = 1.0;
|
||||
let t = PrintTemplate::new(" Proof sheet ", &s).unwrap();
|
||||
assert_eq!(t.name, "Proof sheet");
|
||||
assert!(t.settings.get("guides").is_none());
|
||||
let back = t.settings(&Guides::default()).unwrap();
|
||||
assert_eq!(back.layout.cells.len(), 1);
|
||||
assert_eq!(back.layout.cells[0].photo, None);
|
||||
assert!(back.image.zoom_to_fill);
|
||||
assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0));
|
||||
assert_eq!(back.guides, Guides::default(), "the guides are the caller's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_templates_are_rejected() {
|
||||
for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] {
|
||||
assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}");
|
||||
}
|
||||
let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err();
|
||||
assert!(bad(json!("text")));
|
||||
assert!(bad(json!({"surprise": 1})));
|
||||
assert!(bad(json!({"job": {"ppi": 1e30}})));
|
||||
assert!(bad(json!({"layout": {"pages": 0}})));
|
||||
assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}})));
|
||||
assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}})));
|
||||
assert!(bad(json!({"image": {"borderWidth": -1}})));
|
||||
assert!(!bad(json!({})));
|
||||
let mut s = PrintSettings::default();
|
||||
s.job.ppi = f32::NAN;
|
||||
assert!(PrintTemplate::new("t", &s).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! The print job's colour: which profile composed pages are converted to and how, print sharpening
|
||||
//! for the paper, and the print-ready PDF they end up in.
|
||||
//!
|
||||
//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile
|
||||
//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's
|
||||
//! own values here and names the profile as the document's output intent, so the driver must not
|
||||
//! convert again.
|
||||
//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`].
|
||||
//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened
|
||||
//! at its print size before it goes on the page, with the export sharpener.
|
||||
//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits
|
||||
//! for smaller files.
|
||||
//!
|
||||
//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_codecs::display::MAX_PROFILE_BYTES;
|
||||
use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform};
|
||||
use lightcraft_codecs::{NamedSpace, proof::ProofIntent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep};
|
||||
use crate::print_pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement};
|
||||
|
||||
/// The colour space a printer that manages colour receives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ManagedSpace {
|
||||
#[default]
|
||||
Srgb,
|
||||
AdobeRgb,
|
||||
}
|
||||
|
||||
/// Where the print's colours go.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum PrintProfile {
|
||||
/// Managed by Printer: the page goes in this space and the driver converts it.
|
||||
Managed { space: ManagedSpace },
|
||||
/// A printer / paper profile (an `.icc` / `.icm` file): converted here.
|
||||
Icc { path: PathBuf },
|
||||
}
|
||||
|
||||
impl Default for PrintProfile {
|
||||
fn default() -> Self {
|
||||
PrintProfile::Managed { space: ManagedSpace::Srgb }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrintIntent {
|
||||
#[default]
|
||||
Perceptual,
|
||||
Relative,
|
||||
}
|
||||
|
||||
/// Print sharpening strength (Off leaves the photos as rendered).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrintSharpen {
|
||||
Off,
|
||||
Low,
|
||||
#[default]
|
||||
Standard,
|
||||
High,
|
||||
}
|
||||
|
||||
/// The paper print sharpening is tuned for.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrintMedia {
|
||||
Matte,
|
||||
#[default]
|
||||
Glossy,
|
||||
}
|
||||
|
||||
/// The print job's colour settings, as saved with print templates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrintColorSettings {
|
||||
pub profile: PrintProfile,
|
||||
pub intent: PrintIntent,
|
||||
/// Map the page's black to the paper's darkest (relative colorimetric).
|
||||
pub black_point_compensation: bool,
|
||||
/// Print adjustment, −100..100.
|
||||
pub brightness: f32,
|
||||
/// Print adjustment, −100..100.
|
||||
pub contrast: f32,
|
||||
pub sharpen: PrintSharpen,
|
||||
pub media: PrintMedia,
|
||||
/// 16 bits per component in the PDF (else 8).
|
||||
pub sixteen_bit: bool,
|
||||
}
|
||||
|
||||
impl Default for PrintColorSettings {
|
||||
fn default() -> Self {
|
||||
PrintColorSettings {
|
||||
profile: PrintProfile::default(),
|
||||
intent: PrintIntent::default(),
|
||||
black_point_compensation: true,
|
||||
brightness: 0.0,
|
||||
contrast: 0.0,
|
||||
sharpen: PrintSharpen::default(),
|
||||
media: PrintMedia::default(),
|
||||
sixteen_bit: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintColorSettings {
|
||||
/// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty
|
||||
/// profile path); whether the profile file is usable is checked when the job is built.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] {
|
||||
if !(v.is_finite() && (-100.0..=100.0).contains(&v)) {
|
||||
return Err(format!("{name} must be a number from -100 to 100"));
|
||||
}
|
||||
}
|
||||
if let PrintProfile::Icc { path } = &self.profile
|
||||
&& path.as_os_str().is_empty()
|
||||
{
|
||||
return Err("the printer profile needs a path".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sharpen one photo for the paper, at the size it prints (before it goes on the page).
|
||||
pub fn sharpen_photo(&self, photo: &mut DeepImage) {
|
||||
let amount = match self.sharpen {
|
||||
PrintSharpen::Off => return,
|
||||
PrintSharpen::Low => SharpenAmount::Low,
|
||||
PrintSharpen::Standard => SharpenAmount::Standard,
|
||||
PrintSharpen::High => SharpenAmount::High,
|
||||
};
|
||||
let target = match self.media {
|
||||
PrintMedia::Matte => SharpenFor::Matte,
|
||||
PrintMedia::Glossy => SharpenFor::Glossy,
|
||||
};
|
||||
output_sharpen_deep(photo, target, amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a printer profile, refusing files that can't be one (the parse happens later).
|
||||
fn read_profile(path: &Path) -> Result<Vec<u8>, String> {
|
||||
let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
|
||||
let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len();
|
||||
if len > MAX_PROFILE_BYTES as u64 {
|
||||
return Err(format!("the printer profile {shown} is too large"));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))
|
||||
}
|
||||
|
||||
/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know.
|
||||
#[derive(Debug)]
|
||||
pub struct PrintColorJob {
|
||||
pub settings: PrintColorSettings,
|
||||
transform: PrintTransform,
|
||||
/// The printing condition named in the PDF (only for a printer profile).
|
||||
intent: Option<OutputIntent>,
|
||||
}
|
||||
|
||||
impl PrintColorJob {
|
||||
/// Build the job's conversion. Errors name the profile and what's wrong with it.
|
||||
pub fn new(settings: &PrintColorSettings) -> Result<PrintColorJob, String> {
|
||||
let s = settings.clone();
|
||||
let color = PrintColor {
|
||||
intent: match s.intent {
|
||||
PrintIntent::Perceptual => ProofIntent::Perceptual,
|
||||
PrintIntent::Relative => ProofIntent::Relative,
|
||||
},
|
||||
black_point_compensation: s.black_point_compensation,
|
||||
brightness: s.brightness,
|
||||
contrast: s.contrast,
|
||||
};
|
||||
let (target, name) = match &s.profile {
|
||||
PrintProfile::Managed { space } => {
|
||||
(PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None)
|
||||
}
|
||||
PrintProfile::Icc { path } => {
|
||||
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
(PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name))
|
||||
}
|
||||
};
|
||||
let transform = PrintTransform::new(&target, &color).map_err(|e| match &name {
|
||||
Some(n) => format!("the printer profile {n}: {e}"),
|
||||
None => e,
|
||||
})?;
|
||||
let intent = name.map(|file| OutputIntent {
|
||||
icc: Arc::clone(transform.icc()),
|
||||
channels: transform.channels() as u8,
|
||||
condition: transform.description().map(str::to_owned).unwrap_or(file),
|
||||
});
|
||||
Ok(PrintColorJob { settings: s, transform, intent })
|
||||
}
|
||||
|
||||
/// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]).
|
||||
pub fn sharpen_photo(&self, photo: &mut DeepImage) {
|
||||
self.settings.sharpen_photo(photo);
|
||||
}
|
||||
|
||||
/// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers
|
||||
/// it, in the target's colours.
|
||||
pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result<PdfImage, String> {
|
||||
let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3));
|
||||
if width == 0 || height == 0 || want != Some(rgb.len() as u64) {
|
||||
return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len()));
|
||||
}
|
||||
let device = self.transform.apply(rgb)?;
|
||||
let channels = self.transform.channels() as u8;
|
||||
let icc = Some(Arc::clone(self.transform.icc()));
|
||||
Ok(if self.settings.sixteen_bit {
|
||||
PdfImage::from_u16(width, height, channels, &device, icc)
|
||||
} else {
|
||||
let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect();
|
||||
PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc }
|
||||
})
|
||||
}
|
||||
|
||||
/// The print-ready document: each page image covers its page (`size` in points).
|
||||
pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result<Vec<u8>, String> {
|
||||
let pages: Vec<PdfPage> = pages
|
||||
.into_iter()
|
||||
.map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] })
|
||||
.collect();
|
||||
let title = (!title.trim().is_empty()).then(|| title.to_owned());
|
||||
crate::print_pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() })
|
||||
}
|
||||
|
||||
/// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey).
|
||||
pub fn channels(&self) -> usize {
|
||||
self.transform.channels()
|
||||
}
|
||||
|
||||
/// The profile's description (or file name) for the job's summary.
|
||||
pub fn profile_name(&self) -> Option<&str> {
|
||||
self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description())
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo rendered for the page as the layout gets it from the export renderer.
|
||||
pub fn is_print_ready(photo: &DeepImage) -> bool {
|
||||
photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::print::print_adjust;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A small, flat page of one 16-bit colour.
|
||||
fn page(w: u32, h: u32, v: [u16; 3]) -> Vec<u16> {
|
||||
(0..w * h).flat_map(|_| v).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_default_and_round_trip() {
|
||||
let d = PrintColorSettings::default();
|
||||
assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb });
|
||||
assert!(d.black_point_compensation && d.sixteen_bit);
|
||||
let s = PrintColorSettings {
|
||||
profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() },
|
||||
intent: PrintIntent::Relative,
|
||||
brightness: 12.0,
|
||||
sharpen: PrintSharpen::High,
|
||||
media: PrintMedia::Matte,
|
||||
..d.clone()
|
||||
};
|
||||
let json = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(json["profile"]["kind"], "icc");
|
||||
assert_eq!(json["intent"], "relative");
|
||||
assert_eq!(serde_json::from_value::<PrintColorSettings>(json).unwrap(), s);
|
||||
// partial and unknown-free JSON fills in the defaults
|
||||
let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap();
|
||||
assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d });
|
||||
assert!(serde_json::from_str::<PrintColorSettings>(r#"{"profile":{"kind":"cmyk"}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() {
|
||||
let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap();
|
||||
let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap();
|
||||
assert_eq!((img.channels, img.width, img.height), (3, 4, 2));
|
||||
let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") };
|
||||
assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white");
|
||||
let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap();
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]"));
|
||||
assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent");
|
||||
assert!(s.contains("/Title (Prints)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_profile_converts_and_becomes_the_output_intent() {
|
||||
let dir = scratch("icc");
|
||||
let path = dir.join("Studio Glossy.icc");
|
||||
std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
|
||||
let settings =
|
||||
PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() };
|
||||
let job = PrintColorJob::new(&settings).unwrap();
|
||||
assert_eq!(job.channels(), 3);
|
||||
let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap();
|
||||
assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. }));
|
||||
let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap();
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]);
|
||||
assert!(!s.contains("/Title"), "an empty title is left out");
|
||||
assert!(job.profile_name().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_errors_name_the_file() {
|
||||
let dir = scratch("bad");
|
||||
let garbage = dir.join("Broken Paper.icc");
|
||||
std::fs::write(&garbage, [0u8; 300]).unwrap();
|
||||
let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err();
|
||||
assert!(err.contains("Broken Paper"), "{err}");
|
||||
let missing =
|
||||
PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err();
|
||||
assert!(missing.contains("nope.icc"), "{missing}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_adjustment_reaches_the_page() {
|
||||
let mid = [32768u16; 3];
|
||||
let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap();
|
||||
let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap();
|
||||
let first = |i: &PdfImage| match &i.data {
|
||||
ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]),
|
||||
ImageData::Jpeg(_) | ImageData::Flate { .. } => 0,
|
||||
};
|
||||
assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain));
|
||||
assert!(print_adjust(0.5, 60.0, 0.0) > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_sharpening_follows_the_setting() {
|
||||
// a hard vertical edge in a 16-bit Rec2020 photo
|
||||
let (w, h) = (8usize, 4usize);
|
||||
let samples: Vec<u16> = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect();
|
||||
let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) };
|
||||
assert!(is_print_ready(&photo));
|
||||
let sharpened = |s: PrintSharpen| {
|
||||
let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap();
|
||||
let mut p = photo.clone();
|
||||
job.sharpen_photo(&mut p);
|
||||
p
|
||||
};
|
||||
assert_eq!(sharpened(PrintSharpen::Off), photo);
|
||||
let edge = |p: &DeepImage| match &p.samples {
|
||||
DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]),
|
||||
DeepSamples::F32(_) => (0, 0),
|
||||
};
|
||||
let (dark, light) = edge(&sharpened(PrintSharpen::High));
|
||||
assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_refuses_out_of_range_settings() {
|
||||
assert!(PrintColorSettings::default().validate().is_ok());
|
||||
for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] {
|
||||
assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}");
|
||||
}
|
||||
assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err());
|
||||
assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_pages_are_errors() {
|
||||
let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap();
|
||||
assert!(job.page_image(0, 1, &[]).is_err());
|
||||
assert!(job.page_image(2, 2, &[0; 11]).is_err());
|
||||
assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err());
|
||||
let img = job.page_image(1, 1, &[0; 3]).unwrap();
|
||||
assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err());
|
||||
assert!(job.pdf(vec![], "x").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
//! A small PDF writer for print output: pages of a given size in points, each with raster images
|
||||
//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in
|
||||
//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as
|
||||
//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent:
|
||||
//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers
|
||||
//! expect it in the catalog. Profiles shared by several images are written once.
|
||||
//!
|
||||
//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams,
|
||||
//! deterministic (no dates or IDs), so the same pages always give the same bytes.
|
||||
//!
|
||||
//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces,
|
||||
//! §8.9 images, §14.11.5 output intents). Own design.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Pages per document: a print job beyond this is a mistake, not a book.
|
||||
pub const MAX_PAGES: usize = 1000;
|
||||
/// Images per page.
|
||||
pub const MAX_IMAGES_PER_PAGE: usize = 256;
|
||||
/// Largest page side in points (200 inches): more than any printer's roll.
|
||||
pub const MAX_PAGE_POINTS: f64 = 14_400.0;
|
||||
/// Largest image, in pixels per side.
|
||||
pub const MAX_IMAGE_SIDE: u32 = 65_535;
|
||||
|
||||
/// How an image's samples are stored.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ImageData {
|
||||
/// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed
|
||||
/// losslessly in the file.
|
||||
Raw { bits: u8, samples: Vec<u8> },
|
||||
/// A baseline JPEG stream, copied into the file as it is.
|
||||
Jpeg(Vec<u8>),
|
||||
/// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component.
|
||||
Flate { bits: u8, data: Vec<u8> },
|
||||
}
|
||||
|
||||
/// A raster image and the colour space its samples are in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PdfImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK).
|
||||
pub channels: u8,
|
||||
pub data: ImageData,
|
||||
/// The ICC profile the samples are in; without one the image is device colour.
|
||||
pub icc: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl PdfImage {
|
||||
/// An image from interleaved 16-bit samples.
|
||||
pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option<Arc<Vec<u8>>>) -> PdfImage {
|
||||
let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect();
|
||||
PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc }
|
||||
}
|
||||
|
||||
/// The same image with its samples compressed now (checked first), so a long print doesn't
|
||||
/// hold every page's uncompressed samples until the file is written.
|
||||
pub fn deflated(self) -> Result<PdfImage, String> {
|
||||
self.check()?;
|
||||
let data = match self.data {
|
||||
ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) },
|
||||
other => other,
|
||||
};
|
||||
Ok(PdfImage { data, ..self })
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), String> {
|
||||
if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE {
|
||||
return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height));
|
||||
}
|
||||
if !matches!(self.channels, 1 | 3 | 4) {
|
||||
return Err(format!("images have 1, 3 or 4 components, not {}", self.channels));
|
||||
}
|
||||
match &self.data {
|
||||
ImageData::Raw { bits, samples } => {
|
||||
if !matches!(bits, 8 | 16) {
|
||||
return Err(format!("images have 8 or 16 bits per component, not {bits}"));
|
||||
}
|
||||
let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8);
|
||||
if samples.len() as u64 != want {
|
||||
return Err(format!("the image needs {want} bytes of samples, not {}", samples.len()));
|
||||
}
|
||||
}
|
||||
ImageData::Jpeg(data) => {
|
||||
if !data.starts_with(&[0xFF, 0xD8]) {
|
||||
return Err("the image isn't a JPEG stream".into());
|
||||
}
|
||||
}
|
||||
ImageData::Flate { bits, data } => {
|
||||
if !matches!(bits, 8 | 16) || data.is_empty() {
|
||||
return Err("a compressed image has 8 or 16 bits per component and some data".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an image goes on its page, in points from the page's bottom-left corner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placement {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// One page: its size in points and the images on it, drawn in order (later ones on top).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PdfPage {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub images: Vec<(PdfImage, Placement)>,
|
||||
}
|
||||
|
||||
/// The printing condition the document is made for (PDF/X-style output intent).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OutputIntent {
|
||||
/// The printer / paper profile.
|
||||
pub icc: Arc<Vec<u8>>,
|
||||
/// Its components: 1, 3 or 4.
|
||||
pub channels: u8,
|
||||
/// A name for the condition (the profile's description).
|
||||
pub condition: String,
|
||||
}
|
||||
|
||||
/// Document-level settings.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PdfInfo {
|
||||
pub title: Option<String>,
|
||||
pub output_intent: Option<OutputIntent>,
|
||||
}
|
||||
|
||||
fn finite(v: f64) -> bool {
|
||||
v.is_finite()
|
||||
}
|
||||
|
||||
/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros.
|
||||
fn num(v: f64) -> String {
|
||||
let s = format!("{:.4}", if finite(v) { v } else { 0.0 });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" || s.is_empty() { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark.
|
||||
fn text(s: &str) -> String {
|
||||
if s.bytes().all(|b| (0x20..0x7f).contains(&b)) {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('(');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '(' | ')' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
} else {
|
||||
let mut out = String::from("<FEFF");
|
||||
for unit in s.encode_utf16() {
|
||||
out.push_str(&format!("{unit:04X}"));
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
out: Vec<u8>,
|
||||
offsets: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// Reserve an object number (filled later by [`Writer::object`]).
|
||||
fn reserve(&mut self) -> usize {
|
||||
self.offsets.push(0);
|
||||
self.offsets.len()
|
||||
}
|
||||
|
||||
fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) {
|
||||
if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) {
|
||||
*slot = self.out.len();
|
||||
}
|
||||
self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes());
|
||||
if let Some(data) = stream {
|
||||
self.out.extend_from_slice(b"\nstream\n");
|
||||
self.out.extend_from_slice(data);
|
||||
self.out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
self.out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> Vec<u8> {
|
||||
miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
|
||||
}
|
||||
|
||||
/// Write a document. Errors name the first page or image that can't be written.
|
||||
pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result<Vec<u8>, String> {
|
||||
if pages.is_empty() {
|
||||
return Err("there are no pages to write".into());
|
||||
}
|
||||
if pages.len() > MAX_PAGES {
|
||||
return Err(format!("a document holds at most {MAX_PAGES} pages"));
|
||||
}
|
||||
for (n, page) in pages.iter().enumerate() {
|
||||
let n = n + 1;
|
||||
if !(finite(page.width) && finite(page.height))
|
||||
|| page.width <= 0.0
|
||||
|| page.height <= 0.0
|
||||
|| page.width > MAX_PAGE_POINTS
|
||||
|| page.height > MAX_PAGE_POINTS
|
||||
{
|
||||
return Err(format!("page {n} has no usable size"));
|
||||
}
|
||||
if page.images.len() > MAX_IMAGES_PER_PAGE {
|
||||
return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images"));
|
||||
}
|
||||
for (image, at) in &page.images {
|
||||
image.check().map_err(|e| format!("page {n}: {e}"))?;
|
||||
if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 {
|
||||
return Err(format!("page {n}: an image has no usable placement"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(intent) = &info.output_intent
|
||||
&& !matches!(intent.channels, 1 | 3 | 4)
|
||||
{
|
||||
return Err("the output intent's profile has 1, 3 or 4 components".into());
|
||||
}
|
||||
|
||||
let mut w = Writer { out: Vec::new(), offsets: Vec::new() };
|
||||
w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n");
|
||||
let catalog = w.reserve();
|
||||
let pages_id = w.reserve();
|
||||
let info_id = w.reserve();
|
||||
|
||||
// ICC profiles, each written once (keyed by content) with its component count
|
||||
let mut profiles: HashMap<Vec<u8>, usize> = HashMap::new();
|
||||
let mut profile = |w: &mut Writer, icc: &Arc<Vec<u8>>, n: u8| -> usize {
|
||||
if let Some(&id) = profiles.get(icc.as_slice()) {
|
||||
return id;
|
||||
}
|
||||
let id = w.reserve();
|
||||
let data = deflate(icc);
|
||||
let alternate = match n {
|
||||
1 => "/DeviceGray",
|
||||
4 => "/DeviceCMYK",
|
||||
_ => "/DeviceRGB",
|
||||
};
|
||||
w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data));
|
||||
profiles.insert(icc.to_vec(), id);
|
||||
id
|
||||
};
|
||||
|
||||
let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels));
|
||||
|
||||
let mut kids = Vec::with_capacity(pages.len());
|
||||
for page in pages {
|
||||
let page_id = w.reserve();
|
||||
let mut xobjects = String::new();
|
||||
let mut content = String::new();
|
||||
for (k, (image, at)) in page.images.iter().enumerate() {
|
||||
let space = match &image.icc {
|
||||
Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)),
|
||||
None => match image.channels {
|
||||
1 => "/DeviceGray".into(),
|
||||
4 => "/DeviceCMYK".into(),
|
||||
_ => "/DeviceRGB".into(),
|
||||
},
|
||||
};
|
||||
let id = w.reserve();
|
||||
let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data {
|
||||
ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()),
|
||||
ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()),
|
||||
ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()),
|
||||
};
|
||||
w.object(
|
||||
id,
|
||||
&format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>",
|
||||
image.width,
|
||||
image.height,
|
||||
data.len()
|
||||
),
|
||||
Some(&data),
|
||||
);
|
||||
xobjects.push_str(&format!("/Im{k} {id} 0 R "));
|
||||
content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y)));
|
||||
}
|
||||
let content_id = w.reserve();
|
||||
let stream = deflate(content.as_bytes());
|
||||
w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream));
|
||||
w.object(
|
||||
page_id,
|
||||
&format!(
|
||||
"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>",
|
||||
num(page.width),
|
||||
num(page.height)
|
||||
),
|
||||
None,
|
||||
);
|
||||
kids.push(format!("{page_id} 0 R"));
|
||||
}
|
||||
w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None);
|
||||
|
||||
let intents = match (&info.output_intent, intent_id) {
|
||||
(Some(intent), Some(id)) => format!(
|
||||
" /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]",
|
||||
text(&intent.condition)
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None);
|
||||
let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default();
|
||||
w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None);
|
||||
|
||||
let xref = w.out.len();
|
||||
let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1);
|
||||
for offset in &w.offsets {
|
||||
table.push_str(&format!("{offset:010} 00000 n \n"));
|
||||
}
|
||||
table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1));
|
||||
w.out.extend_from_slice(table.as_bytes());
|
||||
Ok(w.out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::NamedSpace;
|
||||
|
||||
/// The body of object `id` (dictionary and raw stream bytes), found through the xref table.
|
||||
fn object(pdf: &[u8], id: usize) -> (String, Vec<u8>) {
|
||||
// byte offsets: the streams are binary, so search the bytes, not a lossy string
|
||||
let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
|
||||
let tail = std::str::from_utf8(&pdf[start..]).unwrap();
|
||||
let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap();
|
||||
let table = std::str::from_utf8(&pdf[xref..]).unwrap();
|
||||
let line = table.lines().nth(2 + id).unwrap();
|
||||
let offset: usize = line[..10].parse().unwrap();
|
||||
let head = format!("{id} 0 obj\n");
|
||||
assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}");
|
||||
let rest = &pdf[offset + head.len()..];
|
||||
let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap();
|
||||
let body = &rest[..end];
|
||||
match body.windows(8).position(|w| w == b"\nstream\n") {
|
||||
Some(p) => {
|
||||
let data = &body[p + 8..body.len() - "\nendstream".len()];
|
||||
(String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec())
|
||||
}
|
||||
None => (String::from_utf8_lossy(body).into_owned(), vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn inflate(data: &[u8]) -> Vec<u8> {
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()
|
||||
}
|
||||
|
||||
/// Object ids of every image XObject, in file order.
|
||||
fn images(pdf: &[u8]) -> Vec<usize> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn reference(dict: &str, key: &str) -> usize {
|
||||
let at = dict.find(key).unwrap() + key.len();
|
||||
dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
fn srgb() -> Arc<Vec<u8>> {
|
||||
Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb))
|
||||
}
|
||||
|
||||
fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage {
|
||||
PdfPage { width: 612.0, height: 792.0, images }
|
||||
}
|
||||
|
||||
fn full(w: f64, h: f64) -> Placement {
|
||||
Placement { x: 0.0, y: 0.0, width: w, height: h }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_16_bit_icc_image_round_trips() {
|
||||
let samples: Vec<u16> = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect();
|
||||
let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb()));
|
||||
let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-1.6\n"));
|
||||
assert!(pdf.ends_with(b"%%EOF\n"));
|
||||
let ids = images(&pdf);
|
||||
assert_eq!(ids.len(), 1);
|
||||
let (dict, data) = object(&pdf, ids[0]);
|
||||
assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}");
|
||||
let back: Vec<u16> = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect();
|
||||
assert_eq!(back, samples);
|
||||
let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased"));
|
||||
assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}");
|
||||
assert_eq!(inflate(&icc), *srgb());
|
||||
assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_sizes_placements_and_a_shared_profile() {
|
||||
let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb()));
|
||||
let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 };
|
||||
let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }];
|
||||
let pdf = write(&pages, &PdfInfo::default()).unwrap();
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert!(s.contains("/Type /Pages /Count 2"), "two pages");
|
||||
assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]"));
|
||||
// the sRGB profile is written once for all three images
|
||||
assert_eq!(s.matches("/N 3 /Alternate").count(), 1);
|
||||
let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect();
|
||||
assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}");
|
||||
// placements reach the content streams
|
||||
let contents: Vec<String> = s
|
||||
.match_indices("/Contents ")
|
||||
.map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::<usize>().unwrap())
|
||||
.map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap())
|
||||
.collect();
|
||||
assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n");
|
||||
assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_intent_and_title() {
|
||||
let cmyk_like = srgb();
|
||||
let info = PdfInfo {
|
||||
title: Some("Garden – prints (2)".into()),
|
||||
output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }),
|
||||
};
|
||||
let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like));
|
||||
let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap();
|
||||
let (catalog, _) = object(&pdf, 1);
|
||||
assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}");
|
||||
assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}");
|
||||
// the intent and the image share one profile object
|
||||
let dest = reference(&catalog, "/DestOutputProfile");
|
||||
assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest);
|
||||
let (info_dict, _) = object(&pdf, 3);
|
||||
assert!(info_dict.contains("/Title <FEFF"), "non-ASCII titles are UTF-16: {info_dict}");
|
||||
assert!(info_dict.contains("2013"), "the en dash: {info_dict}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_grey_jpeg_and_device_images() {
|
||||
let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9];
|
||||
let imgs = vec![
|
||||
(
|
||||
PdfImage { width: 1, height: 1, channels: 4, data: ImageData::Raw { bits: 8, samples: vec![0, 10, 20, 30] }, icc: None },
|
||||
full(1.0, 1.0),
|
||||
),
|
||||
(PdfImage { width: 2, height: 1, channels: 1, data: ImageData::Raw { bits: 8, samples: vec![0, 255] }, icc: None }, full(1.0, 1.0)),
|
||||
(PdfImage { width: 4, height: 4, channels: 3, data: ImageData::Jpeg(jpeg.clone()), icc: None }, full(1.0, 1.0)),
|
||||
];
|
||||
let pdf = write(&[page(imgs)], &PdfInfo::default()).unwrap();
|
||||
let ids = images(&pdf);
|
||||
let dicts: Vec<_> = ids.iter().map(|&id| object(&pdf, id)).collect();
|
||||
assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8"));
|
||||
assert!(dicts[1].0.contains("/ColorSpace /DeviceGray"));
|
||||
assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB"));
|
||||
assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressing_early_writes_the_same_file() {
|
||||
let samples: Vec<u16> = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect();
|
||||
let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb()));
|
||||
let early = raw.clone().deflated().unwrap();
|
||||
assert!(matches!(early.data, ImageData::Flate { bits: 16, .. }));
|
||||
let at = full(100.0, 75.0);
|
||||
let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap();
|
||||
let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap();
|
||||
assert_eq!(a, b);
|
||||
let mut short = PdfImage::from_u16(4, 3, 3, &samples, None);
|
||||
short.width = 5;
|
||||
assert!(short.deflated().is_err(), "checked before compressing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_documents_are_errors() {
|
||||
let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None);
|
||||
let at = full(10.0, 10.0);
|
||||
let bad = |pages: Vec<PdfPage>| write(&pages, &PdfInfo::default()).is_err();
|
||||
assert!(bad(vec![]));
|
||||
assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }]));
|
||||
assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }]));
|
||||
assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }]));
|
||||
assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])]));
|
||||
assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])]));
|
||||
let mut short = ok();
|
||||
short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] };
|
||||
assert!(bad(vec![page(vec![(short, at)])]));
|
||||
let mut empty = ok();
|
||||
empty.width = 0;
|
||||
assert!(bad(vec![page(vec![(empty, at)])]));
|
||||
let mut odd = ok();
|
||||
odd.channels = 2;
|
||||
assert!(bad(vec![page(vec![(odd, at)])]));
|
||||
let mut not_jpeg = ok();
|
||||
not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]);
|
||||
assert!(bad(vec![page(vec![(not_jpeg, at)])]));
|
||||
let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect();
|
||||
assert!(bad(too_many));
|
||||
let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) };
|
||||
assert!(write(&[page(vec![])], &info).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_strings() {
|
||||
assert_eq!(num(612.0), "612");
|
||||
assert_eq!(num(72.5), "72.5");
|
||||
assert_eq!(num(-0.00001), "0");
|
||||
assert_eq!(num(f64::NAN), "0");
|
||||
assert_eq!(num(1.0 / 3.0), "0.3333");
|
||||
assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)");
|
||||
assert_eq!(text("é"), "<FEFF00E9>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Profiles named by imported presets (issue #1001).
|
||||
//!
|
||||
//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group,
|
||||
//! UUID, amount), and many presets get most of their look from that profile. On import the
|
||||
//! reference is kept in the preset's settings as `profile.look`, and resolved against the
|
||||
//! profiles the user has imported (by name, the group breaking ties), so the preset applies the
|
||||
//! profile like the other app does. The six basic profiles of the other app map to our own basic
|
||||
//! profiles; its other built-in profiles can't be used here and the import says so.
|
||||
//!
|
||||
//! References are resolved again whenever profiles are imported or deleted, so a pack works in
|
||||
//! either order. Only the user's own files are read; no profile content ships with LightCraft.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::lut_profiles::LutProfile;
|
||||
use crate::crs::Props;
|
||||
|
||||
/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`):
|
||||
/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]).
|
||||
pub fn look_ref(props: &Props) -> Option<Value> {
|
||||
let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty());
|
||||
let name = field("Name")?;
|
||||
// stored as a fraction (1 = 100 %), 0–2
|
||||
let amount = field("Amount").and_then(|a| a.parse::<f64>().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0));
|
||||
let mut look = json!({"name": name, "amount": amount});
|
||||
if let Some(g) = field("Group") {
|
||||
look["group"] = json!(g);
|
||||
}
|
||||
if let Some(u) = field("UUID") {
|
||||
look["uuid"] = json!(u.to_ascii_lowercase());
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// What a preset's profile reference resolved to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Resolved {
|
||||
/// A profile the user imported (`lut:…`).
|
||||
Imported(String),
|
||||
/// One of our built-in profiles standing in for the other app's basic profile of that name.
|
||||
BuiltIn(String),
|
||||
/// One of the other app's own creative or camera profiles: not available here.
|
||||
OtherAppBuiltIn,
|
||||
/// A third-party profile the user hasn't imported (yet).
|
||||
NotImported,
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
|
||||
}
|
||||
|
||||
/// The other app's basic profiles and ours that stand in for them.
|
||||
const BASIC: &[(&str, &str)] = &[
|
||||
("adobe color", "lc.color"),
|
||||
("adobe neutral", "lc.neutral"),
|
||||
("adobe vivid", "lc.vivid"),
|
||||
("adobe landscape", "lc.landscape"),
|
||||
("adobe portrait", "lc.portrait"),
|
||||
("adobe monochrome", "lc.mono"),
|
||||
];
|
||||
|
||||
/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered
|
||||
/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`).
|
||||
fn other_app_builtin(name: &str) -> bool {
|
||||
let n = norm(name);
|
||||
if n.starts_with("adobe ") || n.starts_with("camera ") {
|
||||
return true;
|
||||
}
|
||||
["artistic ", "b&w ", "modern ", "vintage "]
|
||||
.iter()
|
||||
.any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit())))
|
||||
}
|
||||
|
||||
/// Resolve `look` (a `profile.look` value) against the imported profiles.
|
||||
pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved {
|
||||
let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default());
|
||||
let group = look.get("group").and_then(Value::as_str).map(norm);
|
||||
if !name.is_empty() {
|
||||
let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect();
|
||||
let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first());
|
||||
if let Some(p) = pick {
|
||||
return Resolved::Imported(p.id.clone());
|
||||
}
|
||||
}
|
||||
if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) {
|
||||
return Resolved::BuiltIn((*ours).to_string());
|
||||
}
|
||||
if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported }
|
||||
}
|
||||
|
||||
/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its
|
||||
/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile)
|
||||
/// and whether `settings` changed.
|
||||
pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> {
|
||||
let profile = settings.get_mut("profile")?.as_object_mut()?;
|
||||
let look = profile.get("look")?.clone();
|
||||
let r = resolve(&look, luts);
|
||||
let before = (profile.get("id").cloned(), profile.get("amount").cloned());
|
||||
match &r {
|
||||
Resolved::Imported(id) | Resolved::BuiltIn(id) => {
|
||||
profile.insert("id".into(), json!(id));
|
||||
profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0)));
|
||||
}
|
||||
// the photo keeps its own profile
|
||||
Resolved::OtherAppBuiltIn | Resolved::NotImported => {
|
||||
profile.remove("id");
|
||||
profile.remove("amount");
|
||||
}
|
||||
}
|
||||
let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned());
|
||||
Some((r, changed))
|
||||
}
|
||||
|
||||
/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names).
|
||||
pub fn report(look: &Value, r: &Resolved) -> Option<String> {
|
||||
let name = look.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
match r {
|
||||
Resolved::Imported(_) | Resolved::BuiltIn(_) => None,
|
||||
Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")),
|
||||
Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve every user preset's profile reference again (after profiles were imported or deleted).
|
||||
/// Only the presets that change are touched, so an unchanged list isn't saved again.
|
||||
pub fn refresh(s: &mut Session) {
|
||||
let luts = s.lut_profiles.clone();
|
||||
let stale: Vec<usize> = s
|
||||
.presets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| !p.builtin)
|
||||
.filter(|(_, p)| {
|
||||
let mut settings = p.settings.clone();
|
||||
apply(&mut settings, &luts).is_some_and(|(_, changed)| changed)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if stale.is_empty() {
|
||||
return;
|
||||
}
|
||||
let presets = &mut *s.presets;
|
||||
for i in stale {
|
||||
if let Some(p) = presets.get_mut(i) {
|
||||
apply(&mut p.settings, &luts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lut(id: &str, name: &str, group: &str) -> LutProfile {
|
||||
LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_by_name_with_the_group_breaking_ties() {
|
||||
let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")];
|
||||
let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0});
|
||||
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into()));
|
||||
let look = json!({"name": "kodak portra 400", "amount": 100.0});
|
||||
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group");
|
||||
assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_app_profiles_map_to_ours_or_are_reported() {
|
||||
assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into()));
|
||||
for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] {
|
||||
assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}");
|
||||
}
|
||||
assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported);
|
||||
// an imported profile wins over the mapping
|
||||
assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sets_and_clears_the_profile() {
|
||||
let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}});
|
||||
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false)));
|
||||
let luts = [lut("lut:mb1", "mb1 profile", "Imported")];
|
||||
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true)));
|
||||
assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0)));
|
||||
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved");
|
||||
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted");
|
||||
assert!(s["profile"].get("id").is_none());
|
||||
assert_eq!(apply(&mut json!({"light": {}}), &luts), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_ref_reads_name_group_uuid_and_amount() {
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0">
|
||||
<crs:Look><rdf:Description crs:Name="mb1 profile" crs:Amount="0.75" crs:UUID="0A1B2C">
|
||||
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Erich McVey B&W Profiles</rdf:li></rdf:Alt></crs:Group>
|
||||
</rdf:Description></crs:Look>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let props = lightcraft_meta::parse_xmp(x).unwrap().properties;
|
||||
assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"})));
|
||||
assert_eq!(look_ref(&Props::new()), None);
|
||||
}
|
||||
}
|
||||
@@ -433,12 +433,31 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
|
||||
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
|
||||
}
|
||||
// Streamed (a large raw or video is never read into memory whole), into a hidden name
|
||||
// beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so
|
||||
// the partial file can't be removed either, leaves no truncated file under the photo's
|
||||
// name for a retry to take for the photo.
|
||||
let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't
|
||||
// block the next
|
||||
let partial = (0..1000u32)
|
||||
.map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id())))
|
||||
.find(|p| !self.exists(p))
|
||||
.ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?;
|
||||
let copied = (|| {
|
||||
let mut from = std::fs::File::open(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?;
|
||||
std::io::copy(&mut from, &mut f)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
let placed = copied.and_then(|()| self.rename_no_replace(&partial, b));
|
||||
if placed.is_err() {
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//! A sidecar is written only beside an original that is an existing file at an absolute path;
|
||||
//! otherwise the save fails.
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
@@ -66,6 +68,13 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
|
||||
/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as
|
||||
/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss).
|
||||
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
||||
sidecar_candidates(original, naming).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
|
||||
/// then under the other naming (the full name also with the photo extension in the other case),
|
||||
/// each as `.xmp`, `.XMP` and `.Xmp`.
|
||||
pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> {
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let base = Path::new(original).with_extension("");
|
||||
@@ -73,7 +82,7 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
||||
let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect();
|
||||
[naming, other]
|
||||
.into_iter()
|
||||
.flat_map(|n| {
|
||||
.flat_map(move |n| {
|
||||
let mut v = vec![sidecar_path(original, n)];
|
||||
if n == SidecarNaming::Full {
|
||||
v.extend(full_variants.iter().map(|o| sidecar_path(o, n)));
|
||||
@@ -81,7 +90,6 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
||||
v
|
||||
})
|
||||
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
|
||||
@@ -626,6 +634,10 @@ impl Session {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
// A relative path would be taken from the working directory.
|
||||
if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
|
||||
return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
|
||||
}
|
||||
let naming = owners.naming(p, self.xmp.naming);
|
||||
let preferred = sidecar_path(orig, naming);
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
@@ -780,6 +792,30 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sidecars_are_written_only_beside_an_existing_original() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
|
||||
let originals = [
|
||||
(r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
|
||||
(dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
|
||||
(dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
|
||||
];
|
||||
for (original, written) in originals {
|
||||
let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
|
||||
let mut s = Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
|
||||
assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
|
||||
assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
|
||||
assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
|
||||
/// gives the photo its keywords as paths.
|
||||
#[test]
|
||||
|
||||
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
|
||||
let my0 = (y - pad).floor().max(-pad);
|
||||
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
|
||||
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
|
||||
if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
|
||||
if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
|
||||
|
||||
+167
-12
@@ -38,7 +38,7 @@
|
||||
//! not cancelled midway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
|
||||
@@ -203,18 +203,37 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
|
||||
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
|
||||
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
// The folder is walked once, and its listing answers for the library's photos in it: on a
|
||||
// network share that is one request for a folder's names where it was several for each
|
||||
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
|
||||
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
|
||||
// (how many photos there are to check is known before the walk: the count shows while it runs)
|
||||
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
let mut listing = Listing::default();
|
||||
let (files, _) = crate::import::expand_noting(
|
||||
std::slice::from_ref(&folder),
|
||||
scan.skip(),
|
||||
crate::walk::Limits::default(),
|
||||
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
|
||||
);
|
||||
let listing = &listing;
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
let (out, checks) = (crate::import::scan_files(scan, files, &progress.files), check_photos(&photos, &labels, listing, progress));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (out, checks) = std::thread::scope(|sc| {
|
||||
let photos = &photos;
|
||||
let labels = &labels;
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, progress));
|
||||
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, listing, progress));
|
||||
let out = crate::import::scan_files(scan, files, &progress.files);
|
||||
// (a check that panicked counts as nothing found; the scan still reports the rest)
|
||||
(out, checks.join().unwrap_or_default())
|
||||
});
|
||||
log::debug!(
|
||||
"synchronize: {} folders listed; the disk was asked {} times about the library's photos",
|
||||
listing.folders.len(),
|
||||
listing.asked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
|
||||
for ((p, _), check) in photos.iter().zip(checks) {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
|
||||
@@ -335,11 +354,136 @@ enum Check {
|
||||
News,
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
/// The listings of the folders a scan walked: what is in each, by name. It answers "is this file
|
||||
/// there" for the library's photos and their sidecars without asking the disk, which on a
|
||||
/// network share is a round trip for every name (and a photo without a sidecar took five).
|
||||
///
|
||||
/// It answers only what holds on any file system. A name is there when the folder lists exactly
|
||||
/// that name ([`asks_the_disk`] aside). It is not there when the folder was listed to its end,
|
||||
/// every name in the folder is ASCII, the name asked about is plain ASCII ([`plain_name`]), and
|
||||
/// no name in the folder is that name in another case. Anything else is not answered, and the
|
||||
/// disk is asked as before: a name that differs in case only, a link, a folder that was not
|
||||
/// walked or not to its end, and every name that is not listed in a folder that holds a name
|
||||
/// outside ASCII. (File systems hold names outside ASCII equal in more ways than can be listed
|
||||
/// here: by case, where the capital sharp s is `SS`; by how accents are written; and some pass
|
||||
/// over marks altogether.)
|
||||
///
|
||||
/// Where the listing and a look-up would differ, the listing is the folder as the scan saw it:
|
||||
/// a file it lists is there though it could not be opened by its path (a folder that may be
|
||||
/// listed but not searched, a path longer than the system takes), and a share that drops after
|
||||
/// the walk does not turn every photo the listing answers for into a missing one.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Listing {
|
||||
folders: HashMap<PathBuf, Listed>,
|
||||
/// How often the disk was asked after all (for the tests, and the log).
|
||||
pub(crate) asked: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
/// One folder's listing.
|
||||
struct Listed {
|
||||
/// name → what it is.
|
||||
names: HashMap<String, crate::walk::EntryKind>,
|
||||
/// The names, lower-cased (only kept for a folder whose names are all ASCII): what a plain
|
||||
/// name asked about could be in another case.
|
||||
lower: std::collections::HashSet<String>,
|
||||
/// Every entry was read, and every name is ASCII: a plain name that is not listed, in any
|
||||
/// case, is not there.
|
||||
sure: bool,
|
||||
}
|
||||
|
||||
/// Whether the disk is asked about `name` whatever the listing holds: a name Windows takes for
|
||||
/// a device (`NUL`, `aux.jpg`: there in every folder, or not openable where it is listed).
|
||||
fn asks_the_disk(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase();
|
||||
// (`COM` and `LPT` with any one digit, the superscript ones Windows also takes among them)
|
||||
let numbered = ["com", "lpt"].iter().any(|port| {
|
||||
let mut rest = stem.strip_prefix(port).unwrap_or_default().chars();
|
||||
matches!((rest.next(), rest.next()), (Some(d), None) if d.is_ascii_digit() || ['\u{b9}', '\u{b2}', '\u{b3}'].contains(&d))
|
||||
});
|
||||
numbered || ["con", "prn", "aux", "nul", "conin$", "conout$", "clock$"].contains(&stem.as_str())
|
||||
}
|
||||
|
||||
/// Whether `name` can only be found under itself in some case: ASCII, and nothing in it that a
|
||||
/// file system resolves another way (a short name with `~`, a stream after `:`, dots or spaces
|
||||
/// at its end, which Windows drops).
|
||||
fn plain_name(name: &str) -> bool {
|
||||
name.is_ascii() && !name.contains(['~', ':']) && !name.ends_with(['.', ' ']) && !name.is_empty()
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
/// Take the listing of `folder` from the walk ([`crate::walk::files_in_noting`]).
|
||||
pub(crate) fn note(&mut self, folder: &Path, entries: Vec<(std::ffi::OsString, crate::walk::EntryKind)>, complete: bool) {
|
||||
let mut sure = complete;
|
||||
let mut names = HashMap::with_capacity(entries.len());
|
||||
let mut lower = std::collections::HashSet::new();
|
||||
for (name, kind) in entries {
|
||||
match name.into_string() {
|
||||
Ok(name) => {
|
||||
sure &= name.is_ascii();
|
||||
lower.insert(name.to_ascii_lowercase());
|
||||
names.insert(name, kind);
|
||||
}
|
||||
// (a name that is no text is not one a photo's path can be compared with)
|
||||
Err(_) => sure = false,
|
||||
}
|
||||
}
|
||||
if !sure {
|
||||
lower.clear();
|
||||
}
|
||||
self.folders.insert(folder.to_path_buf(), Listed { names, lower, sure });
|
||||
}
|
||||
|
||||
/// What the listing of `path`'s folder says of it: its kind when it is listed under exactly
|
||||
/// this name, `Some(None)` when it is surely not there, `None` when the listing can't say.
|
||||
fn kind(&self, path: &Path) -> Option<Option<crate::walk::EntryKind>> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
// (the path ends in that name: no `/.` or separator after it, which the disk would resolve)
|
||||
if !path.to_str()?.ends_with(name) {
|
||||
return None;
|
||||
}
|
||||
if asks_the_disk(name) {
|
||||
return None;
|
||||
}
|
||||
let folder = self.folders.get(path.parent()?)?;
|
||||
if let Some(kind) = folder.names.get(name) {
|
||||
return Some(Some(*kind));
|
||||
}
|
||||
(folder.sure && plain_name(name) && !folder.lower.contains(&name.to_ascii_lowercase())).then_some(None)
|
||||
}
|
||||
|
||||
/// `Path::exists`, where the listing can say.
|
||||
pub(crate) fn exists(&self, path: &Path) -> Option<bool> {
|
||||
match self.kind(path)? {
|
||||
Some(crate::walk::EntryKind::Other) => None,
|
||||
Some(_) => Some(true),
|
||||
None => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Path::is_file`, where the listing can say.
|
||||
pub(crate) fn is_file(&self, path: &Path) -> Option<bool> {
|
||||
match self.kind(path)? {
|
||||
Some(crate::walk::EntryKind::Other) => None,
|
||||
Some(kind) => Some(kind == crate::walk::EntryKind::File),
|
||||
None => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The listing's answer, or the disk's where it has none.
|
||||
fn or_ask(&self, answer: Option<bool>, ask: impl FnOnce() -> bool) -> bool {
|
||||
answer.unwrap_or_else(|| {
|
||||
self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
ask()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, listing: &Listing) -> Check {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
|
||||
if !Path::new(file).exists() {
|
||||
let file = Path::new(file);
|
||||
if !listing.or_ask(listing.exists(file), || file.exists()) {
|
||||
Check::Missing
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, listing) {
|
||||
Check::News
|
||||
} else {
|
||||
Check::Nothing
|
||||
@@ -348,12 +492,12 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
|
||||
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
|
||||
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec<Check> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
progress.to_check.store(photos.len(), Relaxed);
|
||||
let cancel = &progress.files.cancel;
|
||||
let check = |p: &Photo, naming: SidecarNaming| {
|
||||
let c = check_photo(p, naming, labels);
|
||||
let c = check_photo(p, naming, labels, listing);
|
||||
progress.checked.fetch_add(1, Relaxed);
|
||||
c
|
||||
};
|
||||
@@ -421,10 +565,17 @@ impl Session {
|
||||
|
||||
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
|
||||
/// (imported, or edited here) and says something the library doesn't.
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, listing: &Listing) -> bool {
|
||||
let Source::File { path } = &p.source else { return false };
|
||||
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
|
||||
let Ok(meta) = std::fs::metadata(&file) else { return false };
|
||||
// ([`crate::sidecar::find_sidecar`], each name looked up in the listing before the disk)
|
||||
// (a name the listing holds is read for its date next, and one that can't be read is passed
|
||||
// over for the next name, as a name that is no file was)
|
||||
let found = crate::sidecar::sidecar_candidates(path, naming).find_map(|c| {
|
||||
let there = listing.or_ask(listing.is_file(&c), || c.is_file());
|
||||
let meta = there.then(|| std::fs::metadata(&c).ok()).flatten().filter(std::fs::Metadata::is_file)?;
|
||||
Some((c, meta))
|
||||
});
|
||||
let Some((file, meta)) = found else { return false };
|
||||
if meta.len() > MAX_SIDECAR_BYTES {
|
||||
return false;
|
||||
}
|
||||
@@ -708,3 +859,7 @@ impl SyncCommit {
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sync/tests_listing.rs"]
|
||||
mod tests_listing;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Synchronize Folder answers "is the photo's file there" and "has it a sidecar" from the
|
||||
//! listing of the folder its scan has just made, instead of asking the disk about every photo.
|
||||
//!
|
||||
//! On a network share each question is a round trip (about 5 ms a file on a hard disk over SMB,
|
||||
//! measured), and a photo without a sidecar took five of them; the listing has every name.
|
||||
//! The listing answers only what it can be sure of on any file system: where a name could match
|
||||
//! another way (case, letters outside ASCII, a link), the disk is asked as before.
|
||||
use super::*;
|
||||
use crate::walk::EntryKind::{File, Folder, Other};
|
||||
|
||||
fn listing(folders: &[(&str, &[(&str, crate::walk::EntryKind)], bool)]) -> Listing {
|
||||
let mut l = Listing::default();
|
||||
for (dir, names, complete) in folders {
|
||||
l.note(Path::new(dir), names.iter().map(|(n, k)| (std::ffi::OsString::from(n), *k)).collect(), *complete);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Given the listing of a folder, when it is asked about a name, then it says "there" for a
|
||||
/// name it lists, "not there" for one nothing in the folder could be taken for, and nothing
|
||||
/// (ask the disk) wherever a file system could see it otherwise.
|
||||
#[test]
|
||||
fn the_listing_answers_only_what_it_is_sure_of() {
|
||||
let l = listing(&[
|
||||
(
|
||||
"/pics/trip",
|
||||
&[("IMG_1.CR3", File), ("IMG_1.xmp", File), ("img_2.jpg", File), ("sub", Folder), ("link.jpg", Other), (".hidden.xmp", File)],
|
||||
true,
|
||||
),
|
||||
("/pics/cut", &[("IMG_1.CR3", File)], false),
|
||||
("/pics/names", &[("IMG_1.CR3", File), ("Größe.JPG", File)], true),
|
||||
// (the capital sharp s, which a file system that folds case takes for `SS`)
|
||||
("/pics/sharp", &[("IMG_1.CR3", File), ("STRA\u{1e9e}E.jpg", File)], true),
|
||||
("/pics/devices", &[("aux.jpg", File), ("COM\u{b9}.jpg", File), ("IMG_1.CR3", File)], true),
|
||||
]);
|
||||
let (there, gone, ask) = (Some(true), Some(false), None);
|
||||
for (path, exists, is_file) in [
|
||||
("/pics/trip/IMG_1.CR3", there, there),
|
||||
("/pics/trip/IMG_1.xmp", there, there),
|
||||
("/pics/trip/.hidden.xmp", there, there),
|
||||
("/pics/trip/sub", there, gone),
|
||||
("/pics/trip/IMG_9.CR3", gone, gone),
|
||||
("/pics/trip/IMG_1.XMP", ask, ask),
|
||||
("/pics/trip/IMG_2.JPG", ask, ask),
|
||||
("/pics/trip/link.jpg", ask, ask),
|
||||
("/pics/trip/SUB", ask, ask),
|
||||
// a folder the walk did not list to its end, or not at all
|
||||
("/pics/cut/IMG_1.CR3", there, there),
|
||||
("/pics/cut/IMG_2.CR3", ask, ask),
|
||||
("/pics/other/IMG_1.CR3", ask, ask),
|
||||
// a folder that holds a name outside ASCII: what it lists is there, and nothing is surely not there (file
|
||||
// systems hold such names equal in more ways than one)
|
||||
("/pics/names/IMG_1.CR3", there, there),
|
||||
("/pics/names/Größe.JPG", there, there),
|
||||
("/pics/names/IMG_2.CR3", ask, ask),
|
||||
("/pics/names/größe.JPG", ask, ask),
|
||||
("/pics/trip/Größe.JPG", ask, ask),
|
||||
("/pics/sharp/strasse.jpg", ask, ask),
|
||||
("/pics/sharp/STRASSE.xmp", ask, ask),
|
||||
("/pics/sharp/IMG_2.CR3", ask, ask),
|
||||
// names Windows resolves another way: a short name, a stream, dots or spaces at the end, a device
|
||||
("/pics/trip/IMG_10~1.CR3", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3::$DATA", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3.", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3 ", ask, ask),
|
||||
("/pics/devices/aux.jpg", ask, ask),
|
||||
("/pics/devices/NUL", ask, ask),
|
||||
("/pics/devices/nul.xmp", ask, ask),
|
||||
("/pics/devices/COM1 .jpg", ask, ask),
|
||||
("/pics/devices/COM\u{b9}.jpg", ask, ask),
|
||||
("/pics/trip/con", ask, ask),
|
||||
("/pics/trip/PRN.txt", ask, ask),
|
||||
("/pics/trip/com2.jpg", ask, ask),
|
||||
("/pics/trip/LPT9.xmp", ask, ask),
|
||||
("/pics/trip/lpt0", ask, ask),
|
||||
("/pics/trip/nul.tar.gz", ask, ask),
|
||||
("/pics/trip/CONIN$", ask, ask),
|
||||
("/pics/trip/com10.jpg", gone, gone),
|
||||
("/pics/trip/computer.jpg", gone, gone),
|
||||
("/pics/devices/IMG_1.CR3", there, there),
|
||||
("/pics/devices/auxiliary.jpg", ask, ask),
|
||||
("/pics/trip/auxiliary.jpg", gone, gone),
|
||||
// a path that does not end in its file's name is the disk's to resolve
|
||||
("/pics/trip/IMG_1.CR3/", ask, ask),
|
||||
("/pics/trip/IMG_1.CR3/.", ask, ask),
|
||||
("/pics/trip/", ask, ask),
|
||||
("", ask, ask),
|
||||
] {
|
||||
assert_eq!((l.exists(Path::new(path)), l.is_file(Path::new(path))), (exists, is_file), "{path:?}");
|
||||
}
|
||||
// a name that is no text: nothing in its folder is surely not there
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let mut l = Listing::default();
|
||||
l.note(
|
||||
Path::new("/pics/raw"),
|
||||
vec![(std::ffi::OsString::from("IMG_1.CR3"), File), (std::ffi::OsString::from_vec(vec![0x66, 0xff, 0x6f]), File)],
|
||||
true,
|
||||
);
|
||||
assert_eq!((l.exists(Path::new("/pics/raw/IMG_1.CR3")), l.exists(Path::new("/pics/raw/IMG_2.CR3"))), (there, ask));
|
||||
}
|
||||
}
|
||||
|
||||
fn photo(c: &mut Catalog, path: &Path) -> Arc<Photo> {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
c.apply(lightcraft_catalog::Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(id, Source::File { path: path.to_string_lossy().to_string() }, &name, "JPEG", 6, 4, "2020-01-01T00:00:00")),
|
||||
})
|
||||
.unwrap();
|
||||
Arc::clone(c.photo(id).unwrap())
|
||||
}
|
||||
|
||||
/// Given a folder on disk whose photos are there or gone, with sidecars under every name a
|
||||
/// sidecar can have, when each photo is checked with the folder's listing, then the answer is
|
||||
/// the one the disk gives; and for photos with plain names the disk was asked nothing but the
|
||||
/// sidecars' dates.
|
||||
#[test]
|
||||
fn a_check_from_the_listing_is_the_check_from_the_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-listing-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let news = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="5"/></rdf:RDF></x:xmpmeta>"#;
|
||||
for name in [
|
||||
"plain.jpg",
|
||||
"stem.jpg",
|
||||
"stem.xmp",
|
||||
"full.jpg",
|
||||
"full.jpg.xmp",
|
||||
"upper.jpg",
|
||||
"upper.XMP",
|
||||
"sub/deep.jpg",
|
||||
"sub/deep.xmp",
|
||||
"Größe.jpg",
|
||||
"Größe.xmp",
|
||||
"dir.jpg.xmp",
|
||||
] {
|
||||
std::fs::write(dir.join(name), if name.ends_with("mp") || name.ends_with("MP") { news.as_bytes() } else { b"jpeg" }).unwrap();
|
||||
}
|
||||
let mut c = Catalog::new();
|
||||
let photos: Vec<Arc<Photo>> =
|
||||
["plain.jpg", "stem.jpg", "full.jpg", "upper.jpg", "sub/deep.jpg", "Größe.jpg", "gone.jpg", "sub/gone.jpg", "dir.jpg"]
|
||||
.iter()
|
||||
.map(|n| photo(&mut c, &dir.join(n)))
|
||||
.collect();
|
||||
// (`dir.jpg` is gone, though a sidecar of its name is there)
|
||||
let mut l = Listing::default();
|
||||
let walked =
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
|
||||
assert_eq!(walked.files.len(), 12);
|
||||
let nothing = Listing::default();
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
for p in &photos {
|
||||
let from_disk = check_photo(p, naming, &c, ¬hing);
|
||||
assert_eq!(check_photo(p, naming, &c, &l), from_disk, "{:?} {naming:?}", p.file_name);
|
||||
}
|
||||
}
|
||||
let expected = [Check::Nothing, Check::News, Check::News, Check::News, Check::News, Check::News, Check::Missing, Check::Missing, Check::Missing];
|
||||
assert_eq!(photos.iter().map(|p| check_photo(p, SidecarNaming::Stem, &c, &l)).collect::<Vec<_>>(), expected);
|
||||
|
||||
// what the disk was asked about the same photos, with the listing and without: fewer questions with it
|
||||
let asked = |l: &Listing| {
|
||||
for p in &photos {
|
||||
check_photo(p, SidecarNaming::Stem, &c, l);
|
||||
}
|
||||
l.asked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
};
|
||||
let mut with = Listing::default();
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
|
||||
let (with, without) = (asked(&with), asked(&Listing::default()));
|
||||
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
|
||||
// names are found in any case, 21 where they are not)
|
||||
assert!(without >= 20, "{without}");
|
||||
// (with: this folder holds `Größe.*`, so what it does not list is still asked about: the nine names of the
|
||||
// sidecar `plain.jpg` has not (`.xmp`, `.XMP` and `.Xmp` of `plain`, `plain.jpg` and `plain.JPG`), the three
|
||||
// `full.jpg` has not, `upper.xmp`, and the two photos that are gone. What it lists is not asked about, nor
|
||||
// anything in `sub`, whose names are all ASCII)
|
||||
assert_eq!(with, 15, "asked {with} times with the listing, {without} without");
|
||||
|
||||
// a folder of plain names only: the disk is asked nothing at all
|
||||
let sub = dir.join("sub");
|
||||
let mut only_plain = Listing::default();
|
||||
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
|
||||
let mut c2 = Catalog::new();
|
||||
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
|
||||
let p = photo(&mut c2, &sub.join(name));
|
||||
assert_eq!(check_photo(&p, SidecarNaming::Stem, &c2, &only_plain), expected, "{name}");
|
||||
}
|
||||
assert_eq!(only_plain.asked.load(std::sync::atomic::Ordering::Relaxed), 0, "every answer came from the listing");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -313,6 +313,44 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot);
|
||||
}
|
||||
|
||||
/// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight
|
||||
/// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new
|
||||
/// values when it changed, and a plain dab when there is nothing to connect to.
|
||||
#[test]
|
||||
fn connected_brush_strokes_start_where_the_last_one_ended() {
|
||||
use lightcraft_develop::MaskShape;
|
||||
use lightcraft_geom::Point;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let r = s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "connect": true})).unwrap();
|
||||
assert_eq!(r["connected"], false, "nothing to connect to: a dab");
|
||||
let strokes = |s: &Session| {
|
||||
let d = s.develop_of(id).unwrap();
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
strokes.clone()
|
||||
};
|
||||
assert_eq!(strokes(&s)[0].points.len(), 1);
|
||||
let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap();
|
||||
assert_eq!(r["connected"], true);
|
||||
let line = strokes(&s);
|
||||
assert_eq!(line.len(), 2);
|
||||
assert_eq!(line[1].points, vec![Point::new(0.2, 0.5), Point::new(0.6, 0.5)]);
|
||||
// a bigger, softer brush: the segment tapers from the last stroke's values to the new ones
|
||||
let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "feather": 80, "connect": true})).unwrap();
|
||||
assert_eq!(r["connected"], true);
|
||||
let all = strokes(&s);
|
||||
let taper = &all[2..];
|
||||
assert!(taper.len() > 4, "{} pieces", taper.len());
|
||||
assert_eq!(taper[0].points[0], Point::new(0.6, 0.5), "from the last stroke's end");
|
||||
assert_eq!(*taper.last().unwrap().points.last().unwrap(), Point::new(0.6, 0.9), "to the click");
|
||||
assert!(taper.windows(2).all(|w| w[1].size >= w[0].size && w[1].feather >= w[0].feather && w[0].points[1] == w[1].points[0]));
|
||||
assert!((taper.last().unwrap().size - 0.04).abs() < 1e-12 && (taper.last().unwrap().feather - 80.0).abs() < 1e-9);
|
||||
assert!(taper[0].size > 0.02 && taper[0].size < 0.04);
|
||||
// all of it is one undo step
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(strokes(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
|
||||
let mut s = demo();
|
||||
@@ -330,6 +368,35 @@ fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
|
||||
assert!(!json.contains("pressure"), "and stores none: {json}");
|
||||
}
|
||||
|
||||
/// A ⇧-click line carries pen pressure: the last dab's at its start and the click's at its end, stepping along a
|
||||
/// tapered line; with no pressure on either side there is none.
|
||||
#[test]
|
||||
fn connected_brush_strokes_carry_pen_pressure() {
|
||||
use lightcraft_develop::MaskShape;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let strokes = |s: &Session| {
|
||||
let d = s.develop_of(id).unwrap();
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
strokes.clone()
|
||||
};
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "pressure": [0.4]})).unwrap();
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "pressure": [1.0], "connect": true})).unwrap();
|
||||
assert_eq!(strokes(&s)[1].pressure, vec![0.4, 1.0]);
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "pressure": [0.2], "connect": true})).unwrap();
|
||||
let taper = &strokes(&s)[2..];
|
||||
assert!(taper.iter().all(|t| t.pressure.len() == 2));
|
||||
assert!((taper[0].pressure[0] - 1.0).abs() < 1e-6 && (taper.last().unwrap().pressure[1] - 0.2).abs() < 1e-6);
|
||||
assert!(taper.windows(2).all(|w| w[1].pressure[0] <= w[0].pressure[0]));
|
||||
// a mouse: no pressure anywhere
|
||||
let mut m = demo();
|
||||
m.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02})).unwrap();
|
||||
m.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap();
|
||||
let d = m.develop_of(m.active().unwrap()).unwrap();
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
assert!(strokes[1].pressure.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_crop_and_render() {
|
||||
let mut s = demo();
|
||||
@@ -1583,3 +1650,35 @@ fn rotate_carries_the_crop_with_the_image() {
|
||||
assert_eq!(c.geometry.rect, left_half);
|
||||
assert_eq!(c.aspect, Some((2, 3)));
|
||||
}
|
||||
|
||||
/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes
|
||||
/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets
|
||||
/// out still means the selection.
|
||||
#[test]
|
||||
fn malformed_photo_targets_are_refused_not_widened() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap();
|
||||
let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating;
|
||||
let before: Vec<u8> = ids.iter().map(|i| rating(&s, *i)).collect();
|
||||
for bad in [
|
||||
json!({"rating": 5, "id": "5"}),
|
||||
json!({"rating": 5, "ids": [ids[2], "x"]}),
|
||||
json!({"rating": 5, "ids": ids[2].to_string()}),
|
||||
json!({"rating": 5, "id": -1}),
|
||||
] {
|
||||
let err = s.execute("photo.rate", &bad).unwrap_err();
|
||||
assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}");
|
||||
assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::<Vec<_>>(), before, "{bad} changed a rating");
|
||||
}
|
||||
let snapshot = s.catalog.to_snapshot();
|
||||
assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted");
|
||||
// no targets, or a null one: the selection
|
||||
s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap();
|
||||
assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2]));
|
||||
// a preset's id is not a photo target
|
||||
let preset = s.presets.first().unwrap().id.clone();
|
||||
s.execute("preset.apply", &json!({"id": preset})).unwrap();
|
||||
}
|
||||
@@ -579,6 +579,30 @@ fn remove_location_holds_for_original_and_dng_exports() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data
|
||||
/// (it used to be read as `all` and carry both).
|
||||
#[test]
|
||||
fn metadata_none_in_any_case_exports_no_metadata() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap();
|
||||
let export = |s: &mut Session, extra: serde_json::Value| {
|
||||
let mut p = json!({"format": "jpeg", "longEdge": 64});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes)
|
||||
};
|
||||
let all = export(&mut s, json!({}));
|
||||
assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}");
|
||||
for v in ["none", "NONE", "None"] {
|
||||
let m = export(&mut s, json!({"metadata": v}));
|
||||
assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}");
|
||||
}
|
||||
let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true}));
|
||||
assert!(m.gps.is_none() && m.city.is_none(), "{m:?}");
|
||||
}
|
||||
|
||||
/// An original raw whose own metadata holds GPS is refused under "Remove location info".
|
||||
#[test]
|
||||
fn remove_location_refuses_a_raw_that_embeds_gps() {
|
||||
@@ -1000,21 +1024,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
// the writer lives in a block of its own, so its borrow of `written` has ended by the time
|
||||
// the assertion below reads it
|
||||
let files = {
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
for (f, id) in files.iter().zip(&ids) {
|
||||
assert_eq!(f["photo"], id.0, "{f}");
|
||||
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
|
||||
}
|
||||
drop(write);
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
|
||||
// a photo on disk whose folder is gone (moved, an unplugged drive)
|
||||
|
||||
@@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() {
|
||||
/// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass.
|
||||
fn demo_with_yunet() -> Option<Session> {
|
||||
let Some(file) = std::env::var_os("LC_YUNET_MODEL") else {
|
||||
eprintln!("LC_YUNET_MODEL is not set: skipping");
|
||||
lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set");
|
||||
return None;
|
||||
};
|
||||
let dir = temp("yunet");
|
||||
@@ -627,3 +627,17 @@ fn recognition_cannot_be_turned_on_without_a_chosen_model() {
|
||||
assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cpu_only_switch_is_kept_and_bad_values_are_refused() {
|
||||
let d = temp("cpu-only");
|
||||
let mut s = session(&d);
|
||||
assert_eq!(s.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], false);
|
||||
assert_eq!(s.execute("faces.gpu", &json!({"cpuOnly": true})).unwrap()["cpuOnly"], true);
|
||||
// kept with the models, so a new session starts on the CPU too
|
||||
let mut again = session(&d);
|
||||
assert_eq!(again.execute("faces.models.list", &json!({})).unwrap()["gpu"]["cpuOnly"], true);
|
||||
assert!(again.execute("faces.gpu", &json!({"cpuOnly": "yes"})).is_err());
|
||||
assert_eq!(again.execute("faces.gpu", &json!({"cpuOnly": false})).unwrap()["cpuOnly"], false);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user