mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 13:44:23 -04:00
Compare commits
62
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
|
||||
Generated
+40
-33
@@ -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",
|
||||
@@ -3098,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -3115,7 +3120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -3131,7 +3136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
@@ -3161,7 +3166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3170,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3187,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",
|
||||
@@ -3202,7 +3207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3214,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",
|
||||
@@ -3256,7 +3262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"matrixmultiply",
|
||||
@@ -3271,7 +3277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3282,7 +3288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
@@ -3290,7 +3296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3298,12 +3304,13 @@ 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",
|
||||
@@ -3317,7 +3324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
@@ -3325,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",
|
||||
@@ -3341,7 +3348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3352,7 +3359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3368,7 +3375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3380,7 +3387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3398,7 +3405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3410,7 +3417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3422,7 +3429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"jpeg-encoder",
|
||||
@@ -3446,7 +3453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3457,7 +3464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3471,7 +3478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-slideshow"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"serde",
|
||||
@@ -3480,15 +3487,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3497,7 +3504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3526,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -6996,7 +7003,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
@@ -426,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
|
||||
|
||||
+5
-3
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
|---|---:|---:|---:|---|
|
||||
| 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% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-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) |
|
||||
@@ -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%), Italian (92%).
|
||||
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)
|
||||
@@ -191,6 +191,8 @@ 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% |
|
||||
|
||||
@@ -33,6 +33,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -173,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ 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> {
|
||||
|
||||
@@ -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},
|
||||
@@ -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 };
|
||||
|
||||
@@ -1488,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),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -56,6 +57,7 @@ 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1024,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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
|
||||
/// every month, including those with fewer than 31 days.
|
||||
#[test]
|
||||
fn assisted_culling_groups_bursts_across_month_end() {
|
||||
let dir = temp_dir("cull-month-end");
|
||||
for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(w, h);
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let png = lightcraft_codecs::encode_png(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(name), png).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||||
let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
|
||||
for (before, after) in [
|
||||
("2026-01-31T23:59:59", "2026-02-01T00:00:00"),
|
||||
("2026-02-28T23:59:59", "2026-03-01T00:00:00"),
|
||||
("2026-04-30T23:59:59", "2026-05-01T00:00:00"),
|
||||
("2026-06-30T23:59:59", "2026-07-01T00:00:00"),
|
||||
("2026-09-30T23:59:59", "2026-10-01T00:00:00"),
|
||||
("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
|
||||
("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
|
||||
] {
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
|
||||
let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
|
||||
#[test]
|
||||
fn dust_spots_are_found_and_healed() {
|
||||
|
||||
@@ -47,10 +47,35 @@ fn hidden(p: &Path) -> bool {
|
||||
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
}
|
||||
|
||||
/// What a folder's listing says an entry is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Folder,
|
||||
/// A link, or an entry whose kind the listing did not say.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
|
||||
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
|
||||
/// stopped inside the folder, or an entry could not be read).
|
||||
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
|
||||
|
||||
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
|
||||
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
|
||||
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
|
||||
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
|
||||
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked {
|
||||
walk(root, skip, limits, keep, None)
|
||||
}
|
||||
|
||||
/// [`files_in`], handing the listing of every folder it reads to `note`: who walks a folder to
|
||||
/// find its files has, with that, the answer to "is this name there" for everything in it, and
|
||||
/// need not ask the disk again (a round trip for each name on a network share).
|
||||
pub fn files_in_noting(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool, note: NoteFolder) -> Walked {
|
||||
walk(root, skip, limits, keep, Some(note))
|
||||
}
|
||||
|
||||
fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool, mut note: Option<NoteFolder>) -> Walked {
|
||||
let mut out = Walked::default();
|
||||
// canonical folders reached through a link, so a link loop is walked at most once
|
||||
let mut linked: HashSet<PathBuf> = HashSet::new();
|
||||
@@ -77,17 +102,41 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&path) else { continue };
|
||||
let mut children: Vec<(PathBuf, bool)> = Vec::new();
|
||||
for e in rd.flatten() {
|
||||
// (the folder's listing, for who asked for it; all of it unless something below says otherwise)
|
||||
let mut listed = note.is_some().then(Vec::new);
|
||||
let mut complete = true;
|
||||
for e in rd {
|
||||
let Ok(e) = e else {
|
||||
complete = false;
|
||||
continue;
|
||||
};
|
||||
if out.entries >= limits.max_entries {
|
||||
out.truncated = true;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
out.entries = out.entries.saturating_add(1);
|
||||
let p = e.path();
|
||||
if hidden(&p) {
|
||||
// (a hidden entry's kind is only asked for when the listing is wanted; every other
|
||||
// entry's is asked once)
|
||||
let is_hidden = hidden(&p);
|
||||
if is_hidden && listed.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
let ft = e.file_type();
|
||||
if let Some(listed) = listed.as_mut() {
|
||||
let kind = match &ft {
|
||||
Ok(t) if t.is_symlink() => EntryKind::Other,
|
||||
Ok(t) if t.is_dir() => EntryKind::Folder,
|
||||
Ok(t) if t.is_file() => EntryKind::File,
|
||||
_ => EntryKind::Other,
|
||||
};
|
||||
listed.push((e.file_name(), kind));
|
||||
}
|
||||
if is_hidden {
|
||||
continue;
|
||||
}
|
||||
let Ok(ft) = ft else { continue };
|
||||
let dir = if ft.is_symlink() {
|
||||
// a link (or a Windows junction): a folder is entered once, a file is a file
|
||||
match std::fs::metadata(&p) {
|
||||
@@ -105,6 +154,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
|
||||
};
|
||||
children.push((p, dir));
|
||||
}
|
||||
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
|
||||
note(&path, listed, complete);
|
||||
}
|
||||
children.sort();
|
||||
let below = depth.saturating_add(1);
|
||||
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
|
||||
@@ -123,6 +175,43 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked
|
||||
/// with its listings noted, then the walk finds what it finds without them, and each folder's
|
||||
/// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor
|
||||
/// folder) and says it is all of them; a walk that stops inside a folder says it is not.
|
||||
#[test]
|
||||
fn a_walk_notes_each_folders_listing() {
|
||||
let root = tmp("noting");
|
||||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||||
for name in ["a.jpg", ".hidden", "sub/b.jpg"] {
|
||||
std::fs::write(root.join(name), b"x").unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(root.join("a.jpg"), root.join("link.jpg")).unwrap();
|
||||
let mut noted: Vec<(PathBuf, Vec<(String, EntryKind)>, bool)> = Vec::new();
|
||||
let walked = files_in_noting(&root, None, Limits::default(), |_| true, &mut |dir, entries, complete| {
|
||||
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
noted.push((dir.to_path_buf(), entries, complete));
|
||||
});
|
||||
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
|
||||
let named = |n: &str, k: EntryKind| (n.to_string(), k);
|
||||
let mut top = vec![named(".hidden", EntryKind::File), named("a.jpg", EntryKind::File), named("sub", EntryKind::Folder)];
|
||||
if cfg!(unix) {
|
||||
top.insert(2, named("link.jpg", EntryKind::Other));
|
||||
}
|
||||
assert_eq!(noted, [(root.clone(), top, true), (root.join("sub"), vec![named("b.jpg", EntryKind::File)], true)]);
|
||||
|
||||
// stopped inside the first folder: its listing is not all of it
|
||||
let mut cut = Vec::new();
|
||||
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
|
||||
cut.push((dir.to_path_buf(), entries.len(), complete))
|
||||
});
|
||||
assert!(walked.truncated);
|
||||
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
|
||||
let d = tmp("order");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//!
|
||||
//! Everything here treats model files and manifests as hostile input: sizes are capped, numbers must be
|
||||
//! finite and sane, and a malformed file is an error, never a panic. Detection and recognition run
|
||||
//! on the same checked, single-thread pure-Rust CPU interpreter.
|
||||
//! on the same checked, single-thread pure-Rust CPU interpreter; an [`net::Accelerator`] (the GPU, in `lightcraft-gpu`) can
|
||||
//! take its convolutions once it agrees with it.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
|
||||
+451
-3
@@ -9,8 +9,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, PoisonError, RwLock};
|
||||
|
||||
mod kernels;
|
||||
pub mod program;
|
||||
mod shape;
|
||||
|
||||
use crate::graph::{Data, Graph, Node};
|
||||
@@ -64,9 +66,64 @@ impl Tensor {
|
||||
}
|
||||
}
|
||||
|
||||
/// One convolution, as handed to an [`Accelerator`]: batch 1, planar (NCHW) float32, already checked.
|
||||
///
|
||||
/// `out[co][oy][ox] = bias[co] + Σ weights[co][ci][ky][kx] · input[g·cg + ci][oy·sh + ky − pt][ox·sw + kx − pl]`
|
||||
/// with `g = co / (cout / groups)` and zero outside the input.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConvJob<'a> {
|
||||
/// The node's index in the graph: the same for every run of a net, so its weights can stay on the device.
|
||||
pub layer: usize,
|
||||
pub cin: usize,
|
||||
pub h: usize,
|
||||
pub w: usize,
|
||||
pub cout: usize,
|
||||
pub groups: usize,
|
||||
pub kh: usize,
|
||||
pub kw: usize,
|
||||
pub sh: usize,
|
||||
pub sw: usize,
|
||||
pub pt: usize,
|
||||
pub pl: usize,
|
||||
pub oh: usize,
|
||||
pub ow: usize,
|
||||
/// `[cout][cin / groups][kh][kw]`.
|
||||
pub weights: &'a [f32],
|
||||
/// `[cout]`, if the node has one.
|
||||
pub bias: Option<&'a [f32]>,
|
||||
/// `[cin][h][w]`.
|
||||
pub input: &'a [f32],
|
||||
}
|
||||
|
||||
impl ConvJob<'_> {
|
||||
/// Multiply-adds the convolution takes.
|
||||
pub fn macs(&self) -> usize {
|
||||
let per_group = (self.cin / self.groups.max(1)).saturating_mul(self.kh).saturating_mul(self.kw);
|
||||
self.cout.saturating_mul(per_group).saturating_mul(self.oh).saturating_mul(self.ow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something that can run a network's convolutions faster than the CPU kernels (the GPU, in `lightcraft-gpu`).
|
||||
///
|
||||
/// The CPU interpreter stays the reference: a net only uses an accelerator that agreed with it on a test input
|
||||
/// ([`Net::attach_checked`]), and the first error makes the net drop it and run on the CPU again.
|
||||
pub trait Accelerator: Send + Sync {
|
||||
/// What it runs on, for the user ("AMD Radeon RX 7900 (Vulkan)").
|
||||
fn device(&self) -> String;
|
||||
/// `[cout][oh][ow]`, or `Ok(None)` to leave this one to the CPU (too small to be worth a dispatch, say).
|
||||
fn conv(&self, job: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String>;
|
||||
/// The whole network on the device, keeping every value there between steps, for each of `inputs` (all of
|
||||
/// `program.lens[INPUT]` values) in one go: per input, each of `program.outputs` in order. `Ok(None)` leaves the
|
||||
/// run to the CPU path (which may still use [`Accelerator::conv`]).
|
||||
fn run_program(&self, _program: &program::Program, _inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded network, ready to run.
|
||||
pub struct Net {
|
||||
graph: Graph,
|
||||
accelerator: RwLock<Option<Arc<dyn Accelerator>>>,
|
||||
}
|
||||
|
||||
/// Validate semantics rather than silently ignoring unknown or mistyped attributes.
|
||||
@@ -80,7 +137,75 @@ impl Net {
|
||||
for n in &graph.nodes {
|
||||
check(n, &graph)?;
|
||||
}
|
||||
Ok(Net { graph })
|
||||
Ok(Net { graph, accelerator: RwLock::new(None) })
|
||||
}
|
||||
|
||||
/// The accelerator in use, if any.
|
||||
pub fn accelerator(&self) -> Option<Arc<dyn Accelerator>> {
|
||||
self.accelerator.read().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
/// Use `accelerator` for this net's convolutions, but only after it gave the CPU's outputs on `probe` (every
|
||||
/// output value within `tolerance` of the CPU's, relative to that output's largest magnitude) and, with
|
||||
/// `require_faster`, ran it faster than the CPU did (a GPU's per-layer round trip can cost more than a small
|
||||
/// model's layers). Returns why it was not used, so the caller can say so; the net then stays on the CPU.
|
||||
pub fn attach_checked(
|
||||
&self,
|
||||
accelerator: Arc<dyn Accelerator>,
|
||||
probe: &Tensor,
|
||||
tolerance: f32,
|
||||
require_faster: bool,
|
||||
) -> std::result::Result<(), String> {
|
||||
self.detach();
|
||||
let started = web_time::Instant::now();
|
||||
let cpu = self.run(probe.clone()).map_err(|e| e.to_string())?;
|
||||
let cpu_time = started.elapsed();
|
||||
*self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = Some(accelerator);
|
||||
// the first run also uploads the weights and builds the kernels: time a second one
|
||||
let fast = self.run(probe.clone()).map_err(|e| e.to_string());
|
||||
let mut verdict = match fast {
|
||||
Err(e) => Err(e),
|
||||
Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()),
|
||||
Ok(fast) => agree(&cpu, &fast, tolerance),
|
||||
};
|
||||
if verdict.is_ok() && require_faster {
|
||||
let started = web_time::Instant::now();
|
||||
let again = self.run(probe.clone()).map_err(|e| e.to_string());
|
||||
let fast_time = started.elapsed();
|
||||
verdict = match again {
|
||||
Err(e) => Err(e),
|
||||
Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()),
|
||||
Ok(_) if fast_time >= cpu_time => Err(format!(
|
||||
"the device is not faster for this model ({:.0} ms against {:.0} ms on the CPU)",
|
||||
fast_time.as_secs_f64() * 1000.0,
|
||||
cpu_time.as_secs_f64() * 1000.0
|
||||
)),
|
||||
Ok(_) => Ok(()),
|
||||
};
|
||||
}
|
||||
if verdict.is_err() {
|
||||
self.detach();
|
||||
}
|
||||
verdict
|
||||
}
|
||||
|
||||
/// Run on the CPU only from now on.
|
||||
pub fn detach(&self) {
|
||||
*self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// A convolution on the accelerator, if one is attached and takes it. A failing accelerator is dropped.
|
||||
fn accelerated_conv(&self, layer: usize, node: &Node, x: &Tensor) -> Result<Option<Tensor>> {
|
||||
let Some(accelerator) = self.accelerator() else { return Ok(None) };
|
||||
let job = kernels::conv_job(layer, node, &x.shape, &x.data, &self.graph)?;
|
||||
match accelerator.conv(&job) {
|
||||
Ok(Some(out)) if out.len() == job.cout * job.oh * job.ow => Ok(Some(Tensor::new(vec![1, job.cout, job.oh, job.ow], out)?)),
|
||||
Ok(None) => Ok(None),
|
||||
Ok(Some(_)) | Err(_) => {
|
||||
self.detach();
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape the model declares for its input (0 for a dynamic size).
|
||||
@@ -96,7 +221,67 @@ impl Net {
|
||||
|
||||
/// Run the network on `input` (its single input); returns each output by name.
|
||||
pub fn run(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> {
|
||||
shape::tensor(&input.shape, input.data.len(), &input.data)?;
|
||||
self.run_batch(vec![input])?.pop().ok_or_else(|| NetError::Shape("the run gave nothing".into()))
|
||||
}
|
||||
|
||||
/// Run the network on each of `inputs`; an accelerator that keeps the values on its device runs inputs of one shape
|
||||
/// together (one upload and one read-back for all of them).
|
||||
pub fn run_batch(&self, inputs: Vec<Tensor>) -> Result<Vec<Vec<(String, Tensor)>>> {
|
||||
for input in &inputs {
|
||||
shape::tensor(&input.shape, input.data.len(), &input.data)?;
|
||||
}
|
||||
if let Some(out) = self.run_on_device(&inputs)? {
|
||||
return Ok(out);
|
||||
}
|
||||
inputs.into_iter().map(|input| self.run_one(input)).collect()
|
||||
}
|
||||
|
||||
/// The whole batch on the attached accelerator, or `None` to run it on the CPU path. A failing or misbehaving
|
||||
/// accelerator is dropped.
|
||||
fn run_on_device(&self, inputs: &[Tensor]) -> Result<Option<Vec<Vec<(String, Tensor)>>>> {
|
||||
let (Some(accelerator), Some(first)) = (self.accelerator(), inputs.first()) else { return Ok(None) };
|
||||
if inputs.iter().any(|t| t.shape != first.shape) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(program) = self.program(&first.shape)? else { return Ok(None) };
|
||||
let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect();
|
||||
let started = web_time::Instant::now();
|
||||
let outs = match accelerator.run_program(&program, &slices) {
|
||||
Ok(Some(outs)) => outs,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(_) => {
|
||||
self.detach();
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let gathered: Option<Vec<Vec<(String, Tensor)>>> = (outs.len() == inputs.len())
|
||||
.then(|| {
|
||||
outs.into_iter()
|
||||
.map(|values| {
|
||||
(values.len() == program.outputs.len())
|
||||
.then(|| {
|
||||
values
|
||||
.into_iter()
|
||||
.zip(&program.outputs)
|
||||
.map(|(data, (name, _, shape))| Tensor::new(shape.clone(), data).ok().map(|t| (name.clone(), t)))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.flatten();
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
eprintln!("[faces-gpu] {} steps x {}: {:.3} ms", program.steps.len(), inputs.len(), started.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
if gathered.is_none() {
|
||||
// wrong sizes or values that are not finite: the device is not trusted any more
|
||||
self.detach();
|
||||
}
|
||||
Ok(gathered)
|
||||
}
|
||||
|
||||
fn run_one(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> {
|
||||
self.validate_input(&input.shape)?;
|
||||
let mut scratch = kernels::Scratch::default();
|
||||
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
@@ -127,7 +312,13 @@ impl Net {
|
||||
};
|
||||
let started = web_time::Instant::now();
|
||||
let out = match node.op.as_str() {
|
||||
"Conv" => kernels::conv(node, &*get(0)?, g, &mut scratch)?,
|
||||
"Conv" => {
|
||||
let x = get(0)?;
|
||||
match self.accelerated_conv(i, node, &x)? {
|
||||
Some(out) => out,
|
||||
None => kernels::conv(node, &x, g, &mut scratch)?,
|
||||
}
|
||||
}
|
||||
"Relu" => map(&*get(0)?, |v| v.max(0.0)),
|
||||
"Sigmoid" => map(&*get(0)?, |v| 1.0 / (1.0 + (-v).exp())),
|
||||
"Add" | "Sub" | "Mul" => kernels::binary(node.op.as_str(), &*get(0)?, &*get(1)?)?,
|
||||
@@ -168,6 +359,25 @@ impl Net {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two runs' outputs agree: same names and shapes, values within `tolerance` of each other relative to the
|
||||
/// output's largest magnitude.
|
||||
fn agree(a: &[(String, Tensor)], b: &[(String, Tensor)], tolerance: f32) -> std::result::Result<(), String> {
|
||||
if a.len() != b.len() {
|
||||
return Err("the device gave a different number of outputs".into());
|
||||
}
|
||||
for ((name, x), (other, y)) in a.iter().zip(b) {
|
||||
if name != other || x.shape != y.shape {
|
||||
return Err(format!("the device gave `{other}` a different shape"));
|
||||
}
|
||||
let scale = x.data.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6);
|
||||
let worst = x.data.iter().zip(&y.data).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
|
||||
if worst.is_nan() || worst > tolerance * scale {
|
||||
return Err(format!("the device's `{name}` differs from the CPU's by {:.2e} (of {scale:.2e})", worst));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map(x: &Tensor, f: impl Fn(f32) -> f32) -> Tensor {
|
||||
Tensor { shape: x.shape.clone(), data: x.data.iter().map(|v| f(*v)).collect() }
|
||||
}
|
||||
@@ -534,6 +744,244 @@ mod tests {
|
||||
assert_eq!(rs.shape, vec![1, 6, 2]);
|
||||
}
|
||||
|
||||
/// An accelerator built on the slow reference convolution, optionally wrong or failing.
|
||||
struct Fake {
|
||||
skew: f32,
|
||||
fail_after: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
impl Accelerator for Fake {
|
||||
fn device(&self) -> String {
|
||||
"test".into()
|
||||
}
|
||||
fn conv(&self, j: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> {
|
||||
if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 {
|
||||
return Err("device lost".into());
|
||||
}
|
||||
assert!(j.pt == j.pl && j.sh == j.sw);
|
||||
let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap();
|
||||
let zero = vec![0.0; j.cout];
|
||||
let dims = [j.cout, j.cin / j.groups, j.kh, j.kw];
|
||||
let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups);
|
||||
Ok(Some(out.data.iter().map(|v| v + self.skew).collect()))
|
||||
}
|
||||
}
|
||||
|
||||
fn two_convs() -> (Graph, Tensor) {
|
||||
let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))];
|
||||
let mut a = node("Conv", &["x", "w1", "b1"], &attrs);
|
||||
a.outputs = vec!["h".into()];
|
||||
let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]);
|
||||
let mut g = graph(
|
||||
vec![a, b],
|
||||
vec![
|
||||
("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())),
|
||||
("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])),
|
||||
("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())),
|
||||
],
|
||||
);
|
||||
g.inputs = vec![("x".into(), vec![1, 3, 9, 8])];
|
||||
(g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() {
|
||||
let (g, x) = two_convs();
|
||||
let net = Net::new(g).unwrap();
|
||||
let cpu = net.run(x.clone()).unwrap();
|
||||
let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() });
|
||||
net.attach_checked(good, &x, 1e-4, false).unwrap();
|
||||
assert!(net.accelerator().is_some());
|
||||
assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape);
|
||||
let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() });
|
||||
assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err());
|
||||
assert!(net.accelerator().is_none(), "a device that disagrees is not used");
|
||||
assert_eq!(net.run(x).unwrap(), cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() {
|
||||
let (g, x) = two_convs();
|
||||
let net = Net::new(g).unwrap();
|
||||
let cpu = net.run(x.clone()).unwrap();
|
||||
// agrees on the probe (two convs), then fails on the second conv of the next run
|
||||
net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap();
|
||||
let out = net.run(x).unwrap();
|
||||
assert!(net.accelerator().is_none());
|
||||
for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) {
|
||||
assert!((a - b).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
/// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls;
|
||||
/// `poison` makes it give a value that is not finite.
|
||||
struct ProgramFake {
|
||||
calls: std::sync::atomic::AtomicUsize,
|
||||
poison: bool,
|
||||
}
|
||||
|
||||
fn run_steps(p: &program::Program, input: &[f32]) -> Vec<Vec<f32>> {
|
||||
use program::{Arg, Binary, Op, Unary};
|
||||
let mut values: Vec<Vec<f32>> = vec![Vec::new(); p.lens.len()];
|
||||
values[program::INPUT] = input.to_vec();
|
||||
for step in &p.steps {
|
||||
let arg = |i: usize| -> Vec<f32> {
|
||||
match &step.args[i] {
|
||||
Arg::Value(v) => values[*v].clone(),
|
||||
Arg::Const { data, .. } => data.to_vec(),
|
||||
}
|
||||
};
|
||||
let len = p.lens[step.out];
|
||||
let x = arg(0);
|
||||
let out: Vec<f32> = match &step.op {
|
||||
Op::Conv(j) => {
|
||||
let (m, cg) = (j.cout / j.groups, j.cin / j.groups);
|
||||
let mut out = vec![0f32; len];
|
||||
for co in 0..j.cout {
|
||||
for oy in 0..j.oh {
|
||||
for ox in 0..j.ow {
|
||||
let mut v = j.bias.map_or(0.0, |b| b[co]);
|
||||
for ci in 0..cg {
|
||||
for ky in 0..j.kh {
|
||||
for kx in 0..j.kw {
|
||||
let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize);
|
||||
if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w {
|
||||
v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx]
|
||||
* x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out[(co * j.oh + oy) * j.ow + ox] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(),
|
||||
Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(),
|
||||
Op::ChannelAffine { channels, plane } => {
|
||||
let (s, o) = (arg(1), arg(2));
|
||||
x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect()
|
||||
}
|
||||
Op::ChannelPrelu { channels, plane } => {
|
||||
let a = arg(1);
|
||||
x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect()
|
||||
}
|
||||
Op::Binary { op, dims, a, b } => {
|
||||
let y = arg(1);
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
let (mut rest, mut ia, mut ib) = (i, 0, 0);
|
||||
for d in (0..4).rev() {
|
||||
let at = rest % dims[d];
|
||||
rest /= dims[d];
|
||||
ia += at * a[d];
|
||||
ib += at * b[d];
|
||||
}
|
||||
match op {
|
||||
Binary::Add => x[ia] + y[ib],
|
||||
Binary::Sub => x[ia] - y[ib],
|
||||
Binary::Mul => x[ia] * y[ib],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Op::Transpose { dims, strides } => (0..len)
|
||||
.map(|i| {
|
||||
let (mut rest, mut from) = (i, 0);
|
||||
for d in (0..4).rev() {
|
||||
from += (rest % dims[d]) * strides[d];
|
||||
rest /= dims[d];
|
||||
}
|
||||
x[from]
|
||||
})
|
||||
.collect(),
|
||||
Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len)
|
||||
.map(|i| {
|
||||
let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh));
|
||||
let mut m = f32::NEG_INFINITY;
|
||||
for ky in 0..*kh {
|
||||
for kx in 0..*kw {
|
||||
m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]);
|
||||
}
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect(),
|
||||
Op::Resize { h, w, sh, sw, .. } => (0..len)
|
||||
.map(|i| {
|
||||
let (ow, oh) = (w * sw, h * sh);
|
||||
let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh));
|
||||
x[(c * h + oy / sh) * w + ox / sw]
|
||||
})
|
||||
.collect(),
|
||||
Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::<f32>() / *plane as f32).collect(),
|
||||
Op::Gemv { k, n, trans_b, alpha } => {
|
||||
let (wt, bias) = (arg(1), arg(2));
|
||||
(0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::<f32>() + bias[o]).collect()
|
||||
}
|
||||
};
|
||||
assert_eq!(out.len(), len, "{:?}", step.op);
|
||||
values[step.out] = out;
|
||||
for f in &step.free {
|
||||
values[*f] = Vec::new();
|
||||
}
|
||||
}
|
||||
p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect()
|
||||
}
|
||||
|
||||
impl Accelerator for ProgramFake {
|
||||
fn device(&self) -> String {
|
||||
"program test".into()
|
||||
}
|
||||
fn conv(&self, _: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut out: Vec<Vec<Vec<f32>>> = inputs.iter().map(|x| run_steps(p, x)).collect();
|
||||
if self.poison {
|
||||
out[0][0][0] = f32::NAN;
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn every_operator_input(seed: usize) -> Tensor {
|
||||
input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_of_every_operator_gives_what_the_interpreter_gives() {
|
||||
let net = Net::new(crate::synthetic::every_operator_graph()).unwrap();
|
||||
let x = every_operator_input(0);
|
||||
let cpu = net.run(x.clone()).unwrap();
|
||||
let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false });
|
||||
net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap();
|
||||
let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(calls > 0, "the whole network ran as one program");
|
||||
// several inputs go to the device together, and each gets its own outputs
|
||||
let inputs: Vec<Tensor> = (0..3).map(every_operator_input).collect();
|
||||
let together = net.run_batch(inputs.clone()).unwrap();
|
||||
assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch");
|
||||
net.detach();
|
||||
for (x, got) in inputs.into_iter().zip(&together) {
|
||||
let want = net.run(x).unwrap();
|
||||
agree(&want, got, 1e-5).unwrap();
|
||||
}
|
||||
assert_eq!(cpu.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_that_gives_values_that_are_not_finite_is_dropped() {
|
||||
let net = Net::new(crate::synthetic::every_operator_graph()).unwrap();
|
||||
let x = every_operator_input(1);
|
||||
let cpu = net.run(x.clone()).unwrap();
|
||||
assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err());
|
||||
assert!(net.accelerator().is_none());
|
||||
assert_eq!(net.run(x).unwrap(), cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_inconsistent_models_are_errors() {
|
||||
let x = input(1, 3, 3, |i| i as f32);
|
||||
|
||||
@@ -59,6 +59,33 @@ fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, p
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The checked geometry of a convolution node, for an accelerator.
|
||||
pub(super) fn conv_job<'a>(layer: usize, n: &Node, shape: &[usize], input: &'a [f32], g: &'a Graph) -> Result<ConvJob<'a>> {
|
||||
let (cin, h, w) = program::chw_of(shape)?;
|
||||
let wt = shape::weight(n, g, 1)?;
|
||||
let weights = shape::floats(n, g, 1)?;
|
||||
let [cout, cg, kh, kw] = wt.dims.as_slice() else {
|
||||
return shape_err("invalid convolution weights");
|
||||
};
|
||||
let (cout, cg, kh, kw) = (*cout, *cg, *kh, *kw);
|
||||
let groups = usize::try_from(n.int("group").unwrap_or(1)).map_err(|_| NetError::Shape("invalid group".into()))?;
|
||||
let (sh, sw) = pair(n, "strides", 1)?;
|
||||
let (pt, pl, pb, pr) = pads(n)?;
|
||||
let ph = h.checked_add(pt).and_then(|v| v.checked_add(pb)).ok_or_else(|| NetError::Shape("padding overflow".into()))?;
|
||||
let pw = w.checked_add(pl).and_then(|v| v.checked_add(pr)).ok_or_else(|| NetError::Shape("padding overflow".into()))?;
|
||||
if ph < kh || pw < kw || groups == 0 || cg == 0 || kh == 0 || kw == 0 || cin != groups * cg || cout % groups != 0 {
|
||||
return shape_err("invalid convolution shape");
|
||||
}
|
||||
if cout.checked_mul(cg).and_then(|v| v.checked_mul(kh * kw)) != Some(weights.len()) {
|
||||
return shape_err("short convolution weights");
|
||||
}
|
||||
let bias = if n.inputs.get(2).is_some_and(|s| !s.is_empty()) { Some(shape::floats(n, g, 2)?) } else { None };
|
||||
if bias.is_some_and(|b| b.len() != cout) {
|
||||
return shape_err("the convolution bias does not match");
|
||||
}
|
||||
let (oh, ow) = ((ph - kh) / sh + 1, (pw - kw) / sw + 1);
|
||||
Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input })
|
||||
}
|
||||
pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result<Tensor> {
|
||||
let (cin, h, w) = chw(x)?;
|
||||
let wt = shape::weight(n, g, 1)?;
|
||||
@@ -181,7 +208,7 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re
|
||||
Ok(out)
|
||||
}
|
||||
/// Flat offset under ONNX's trailing-dimension broadcasting; shapes are checked before execution.
|
||||
fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize {
|
||||
pub(super) fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize {
|
||||
let mut offset = 0;
|
||||
let mut stride = 1;
|
||||
for (axis, &size) in out.iter().enumerate().rev() {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! A network lowered to a flat list of steps over numbered values, for an [`Accelerator`] that keeps every activation
|
||||
//! on its device between layers (one upload, one read-back per run instead of one per convolution).
|
||||
//!
|
||||
//! Shapes are resolved for one input shape; every step names the values it reads and the one it writes, and which
|
||||
//! values nothing reads after it. Shape-only operators (`Reshape`, `Flatten`, `Identity`, `Dropout`) make no step: their
|
||||
//! output is the same value under another name. Anything this cannot express (a broadcast past four dimensions, a
|
||||
//! transposed or batched `Gemm`, a slope that is not per channel) makes the whole lowering `None`, and the net runs
|
||||
//! the CPU path instead.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Value 0 is the network's input.
|
||||
pub const INPUT: usize = 0;
|
||||
|
||||
/// An operand: a value computed on the device, or a constant (uploaded once and kept, by `id`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Arg<'a> {
|
||||
Value(usize),
|
||||
/// `id` is the same for every run of the same net, so the device can keep the data.
|
||||
Const {
|
||||
id: usize,
|
||||
data: Cow<'a, [f32]>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Unary {
|
||||
Relu,
|
||||
Sigmoid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Binary {
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
}
|
||||
|
||||
/// What a step computes. Element counts come from the program's `lens` of the step's output.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Op<'a> {
|
||||
/// A convolution; its `input` is empty (it reads the step's first argument).
|
||||
Conv(ConvJob<'a>),
|
||||
/// `out[i] = f(x[i])`. Args: `[x]`.
|
||||
Unary(Unary),
|
||||
/// `out[i] = x[i] * scale[c] + offset[c]`, `c = (i / plane) % channels`. Args: `[x, scale, offset]`.
|
||||
ChannelAffine { channels: usize, plane: usize },
|
||||
/// `out[i] = x[i] < 0 ? x[i] * slope[c] : x[i]`. Args: `[x, slope]` (`channels` values).
|
||||
ChannelPrelu { channels: usize, plane: usize },
|
||||
/// Broadcasting over four dimensions: `dims` of the output, element strides of each operand (0 where it repeats).
|
||||
/// Args: `[a, b]`.
|
||||
Binary { op: Binary, dims: [usize; 4], a: [usize; 4], b: [usize; 4] },
|
||||
/// `out[n] = alpha · Σk x[k] · B(k, n) + bias[n]`, `B(k, n) = weights[n·k_len + k]` when `trans_b`, else
|
||||
/// `weights[k·n_len + n]`. Args: `[x, weights, bias]` (bias already times beta; zeros when the node has none).
|
||||
Gemv { k: usize, n: usize, trans_b: bool, alpha: f32 },
|
||||
/// The mean of each `plane`-sized run. Args: `[x]`.
|
||||
GlobalAverage { channels: usize, plane: usize },
|
||||
/// Unpadded max pooling. Args: `[x]`.
|
||||
MaxPool { c: usize, h: usize, w: usize, kh: usize, kw: usize, sh: usize, sw: usize, oh: usize, ow: usize },
|
||||
/// Nearest-neighbour upscale by whole numbers. Args: `[x]`.
|
||||
Resize { c: usize, h: usize, w: usize, sh: usize, sw: usize },
|
||||
/// `out` in its own order (`dims`), read from the input at `strides` (the input's strides, permuted). Args: `[x]`.
|
||||
Transpose { dims: [usize; 4], strides: [usize; 4] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Step<'a> {
|
||||
pub op: Op<'a>,
|
||||
pub args: Vec<Arg<'a>>,
|
||||
pub out: usize,
|
||||
/// Values nothing reads after this step (never an output): their memory can be reused.
|
||||
pub free: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A whole network for one input shape.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Program<'a> {
|
||||
/// Elements of each value; `lens[INPUT]` is the input's.
|
||||
pub lens: Vec<usize>,
|
||||
pub steps: Vec<Step<'a>>,
|
||||
/// Each graph output: its name, the value that holds it and its shape.
|
||||
pub outputs: Vec<(String, usize, Vec<usize>)>,
|
||||
}
|
||||
|
||||
/// Leading ones up to rank four, or `None` past four.
|
||||
fn rank4(s: &[usize]) -> Option<[usize; 4]> {
|
||||
let pad = 4usize.checked_sub(s.len())?;
|
||||
let mut out = [1; 4];
|
||||
for (i, d) in s.iter().enumerate() {
|
||||
*out.get_mut(pad + i)? = *d;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Element strides of `input` read as `out` under trailing-dimension broadcasting (0 along repeated axes).
|
||||
fn broadcast_strides(out: [usize; 4], input: &[usize]) -> Option<[usize; 4]> {
|
||||
let dims = rank4(input)?;
|
||||
let mut strides = [0; 4];
|
||||
let mut step = 1;
|
||||
for axis in (0..4).rev() {
|
||||
let (d, o) = (*dims.get(axis)?, *out.get(axis)?);
|
||||
if d == o && d != 1 {
|
||||
*strides.get_mut(axis)? = step;
|
||||
} else if d != 1 {
|
||||
return None;
|
||||
}
|
||||
step *= d;
|
||||
}
|
||||
Some(strides)
|
||||
}
|
||||
|
||||
impl Net {
|
||||
/// This network lowered for `input` (its shape), or `None` when a step has no device form.
|
||||
pub fn program(&self, input: &[usize]) -> Result<Option<Program<'_>>> {
|
||||
let g = &self.graph;
|
||||
let (shapes, _) = shape::infer_all(g, input)?;
|
||||
let Some((input_name, _)) = g.inputs.first() else { return shape_err("the model has no input") };
|
||||
let mut lens = vec![shape::elements(input)?];
|
||||
let mut names: HashMap<&str, (usize, Vec<usize>)> = HashMap::new();
|
||||
names.insert(input_name.as_str(), (INPUT, input.to_vec()));
|
||||
let mut steps: Vec<Step> = Vec::new();
|
||||
for (i, node) in g.nodes.iter().enumerate() {
|
||||
let Some(out_shape) = shapes.get(i) else { return shape_err("a node has no shape") };
|
||||
let Some(out_name) = node.outputs.first() else { return shape_err("a node has no output") };
|
||||
let value = |idx: usize| -> Option<&(usize, Vec<usize>)> { node.inputs.get(idx).and_then(|n| names.get(n.as_str())) };
|
||||
let constant = |idx: usize| -> Option<(Cow<'_, [f32]>, &[usize])> {
|
||||
let w = node.inputs.get(idx).and_then(|n| g.weights.get(n))?;
|
||||
match &w.data {
|
||||
Data::F32(v) => Some((Cow::Borrowed(v.as_slice()), w.dims.as_slice())),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let id = |k: usize| i * 4 + k;
|
||||
let Some((x, x_shape)) = value(0).cloned() else { return Ok(None) };
|
||||
let (op, args) = match node.op.as_str() {
|
||||
"Reshape" | "Flatten" | "Identity" | "Dropout" => {
|
||||
names.insert(out_name.as_str(), (x, out_shape.clone()));
|
||||
continue;
|
||||
}
|
||||
"Conv" => (Op::Conv(kernels::conv_job(i, node, &x_shape, &[], g)?), vec![Arg::Value(x)]),
|
||||
"Relu" => (Op::Unary(Unary::Relu), vec![Arg::Value(x)]),
|
||||
"Sigmoid" => (Op::Unary(Unary::Sigmoid), vec![Arg::Value(x)]),
|
||||
"BatchNormalization" => {
|
||||
let channels = *x_shape.get(1).ok_or_else(|| NetError::Shape("BatchNormalization rank".into()))?;
|
||||
let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?;
|
||||
let (s, b, m, v) =
|
||||
(shape::floats(node, g, 1)?, shape::floats(node, g, 2)?, shape::floats(node, g, 3)?, shape::floats(node, g, 4)?);
|
||||
let eps = shape::float(node, "epsilon", 1e-5);
|
||||
let mut scale = Vec::with_capacity(channels);
|
||||
let mut offset = Vec::with_capacity(channels);
|
||||
for c in 0..channels {
|
||||
let (Some(s), Some(b), Some(m), Some(v)) = (s.get(c), b.get(c), m.get(c), v.get(c)) else {
|
||||
return shape_err("short BatchNormalization parameters");
|
||||
};
|
||||
let k = s / (v + eps).sqrt();
|
||||
scale.push(k);
|
||||
offset.push(b - m * k);
|
||||
}
|
||||
(
|
||||
Op::ChannelAffine { channels, plane },
|
||||
vec![Arg::Value(x), Arg::Const { id: id(1), data: scale.into() }, Arg::Const { id: id(2), data: offset.into() }],
|
||||
)
|
||||
}
|
||||
"PRelu" => {
|
||||
let Some((slope, dims)) = constant(1) else { return Ok(None) };
|
||||
let channels = x_shape.get(1).copied().unwrap_or(1);
|
||||
let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?;
|
||||
let mut expanded = vec![1; x_shape.len().saturating_sub(dims.len())];
|
||||
expanded.extend_from_slice(dims);
|
||||
let per_channel = expanded.iter().enumerate().all(|(i, &d)| d == 1 || i == 1) && slope.len() == channels;
|
||||
let slope: Cow<[f32]> = if per_channel {
|
||||
slope
|
||||
} else if let [one] = slope.as_ref() {
|
||||
vec![*one; channels].into()
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
(Op::ChannelPrelu { channels, plane }, vec![Arg::Value(x), Arg::Const { id: id(1), data: slope }])
|
||||
}
|
||||
"Add" | "Sub" | "Mul" => {
|
||||
let op = match node.op.as_str() {
|
||||
"Add" => Binary::Add,
|
||||
"Sub" => Binary::Sub,
|
||||
_ => Binary::Mul,
|
||||
};
|
||||
let (b, b_shape) = match (value(1), constant(1)) {
|
||||
(Some((v, s)), _) => (Arg::Value(*v), s.clone()),
|
||||
(None, Some((data, dims))) => (Arg::Const { id: id(1), data }, dims.to_vec()),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let Some(dims) = rank4(out_shape) else { return Ok(None) };
|
||||
let (Some(sa), Some(sb)) = (broadcast_strides(dims, &x_shape), broadcast_strides(dims, &b_shape)) else { return Ok(None) };
|
||||
(Op::Binary { op, dims, a: sa, b: sb }, vec![Arg::Value(x), b])
|
||||
}
|
||||
"Gemm" => {
|
||||
let Some((weights, wdims)) = constant(1) else { return Ok(None) };
|
||||
let ([1, k], [n_out]) = (x_shape.as_slice(), out_shape.get(1..).unwrap_or(&[])) else { return Ok(None) };
|
||||
if node.int("transA").unwrap_or(0) != 0 || out_shape.first() != Some(&1) {
|
||||
return Ok(None);
|
||||
}
|
||||
let trans_b = node.int("transB").unwrap_or(0) != 0;
|
||||
if wdims.len() != 2 || weights.len() != k * n_out {
|
||||
return shape_err("Gemm weights do not match");
|
||||
}
|
||||
let mut bias = vec![0.0f32; *n_out];
|
||||
if node.inputs.get(2).is_some_and(|s| !s.is_empty()) {
|
||||
let wt = shape::weight(node, g, 2)?;
|
||||
let b = shape::floats(node, g, 2)?;
|
||||
let beta = shape::float(node, "beta", 1.0);
|
||||
for (j, to) in bias.iter_mut().enumerate() {
|
||||
*to = beta
|
||||
* b.get(kernels::broadcast_offset(j, out_shape, &wt.dims))
|
||||
.copied()
|
||||
.ok_or_else(|| NetError::Shape("short matrix bias".into()))?;
|
||||
}
|
||||
}
|
||||
(
|
||||
Op::Gemv { k: *k, n: *n_out, trans_b, alpha: shape::float(node, "alpha", 1.0) },
|
||||
vec![Arg::Value(x), Arg::Const { id: id(1), data: weights }, Arg::Const { id: id(2), data: bias.into() }],
|
||||
)
|
||||
}
|
||||
"GlobalAveragePool" => {
|
||||
let (c, h, w) = chw_of(&x_shape)?;
|
||||
(Op::GlobalAverage { channels: c, plane: h * w }, vec![Arg::Value(x)])
|
||||
}
|
||||
"MaxPool" => {
|
||||
let (c, h, w) = chw_of(&x_shape)?;
|
||||
let (kh, kw) = pair(node, "kernel_shape", 1)?;
|
||||
let (sh, sw) = pair(node, "strides", 1)?;
|
||||
if pads(node)? != (0, 0, 0, 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (_, oh, ow) = chw_of(out_shape)?;
|
||||
(Op::MaxPool { c, h, w, kh, kw, sh, sw, oh, ow }, vec![Arg::Value(x)])
|
||||
}
|
||||
"Resize" => {
|
||||
let (c, h, w) = chw_of(&x_shape)?;
|
||||
let (_, oh, ow) = chw_of(out_shape)?;
|
||||
if oh % h != 0 || ow % w != 0 {
|
||||
return shape_err("a Resize that is not by whole numbers");
|
||||
}
|
||||
(Op::Resize { c, h, w, sh: oh / h, sw: ow / w }, vec![Arg::Value(x)])
|
||||
}
|
||||
"Transpose" => {
|
||||
let rank = x_shape.len();
|
||||
let default_perm: Vec<i64> = (0..rank as i64).rev().collect();
|
||||
let perm = node.ints("perm").unwrap_or(&default_perm);
|
||||
if rank > 4 || perm.len() != rank {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut in_strides = vec![1usize; rank];
|
||||
for a in (0..rank.saturating_sub(1)).rev() {
|
||||
let next = in_strides.get(a + 1).copied().unwrap_or(1) * x_shape.get(a + 1).copied().unwrap_or(1);
|
||||
if let Some(s) = in_strides.get_mut(a) {
|
||||
*s = next;
|
||||
}
|
||||
}
|
||||
let Some(dims) = rank4(out_shape) else { return Ok(None) };
|
||||
let mut strides = [0usize; 4];
|
||||
for (j, p) in perm.iter().enumerate() {
|
||||
let p = usize::try_from(*p).map_err(|_| NetError::Shape("a negative permutation".into()))?;
|
||||
let s = in_strides.get(p).copied().ok_or_else(|| NetError::Shape("a bad permutation".into()))?;
|
||||
*strides.get_mut(4 - rank + j).ok_or_else(|| NetError::Shape("a bad permutation".into()))? = s;
|
||||
}
|
||||
(Op::Transpose { dims, strides }, vec![Arg::Value(x)])
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let out = lens.len();
|
||||
lens.push(shape::elements(out_shape)?);
|
||||
names.insert(out_name.as_str(), (out, out_shape.clone()));
|
||||
steps.push(Step { op, args, out, free: Vec::new() });
|
||||
}
|
||||
let outputs = g
|
||||
.outputs
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let (v, s) = names.get(name.as_str()).ok_or_else(|| NetError::Shape(format!("output `{name}` was never computed")))?;
|
||||
Ok((name.clone(), *v, s.clone()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// the last step that reads each value; a value nothing reads is free right after it is made
|
||||
let mut last: Vec<Option<usize>> = vec![None; lens.len()];
|
||||
for (at, step) in steps.iter().enumerate() {
|
||||
if let Some(l) = last.get_mut(step.out) {
|
||||
*l = Some(at);
|
||||
}
|
||||
for a in &step.args {
|
||||
if let Arg::Value(v) = a
|
||||
&& let Some(l) = last.get_mut(*v)
|
||||
{
|
||||
*l = Some(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (v, at) in last.iter().enumerate() {
|
||||
if outputs.iter().any(|(_, o, _)| *o == v) {
|
||||
continue;
|
||||
}
|
||||
if let Some(step) = at.and_then(|at| steps.get_mut(at)) {
|
||||
step.free.push(v);
|
||||
}
|
||||
}
|
||||
Ok(Some(Program { lens, steps, outputs }))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn chw_of(s: &[usize]) -> Result<(usize, usize, usize)> {
|
||||
match s {
|
||||
[1, c, h, w] => Ok((*c, *h, *w)),
|
||||
s => unsupported(format!("a tensor of shape {s:?} (only [1, channels, height, width])")),
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,12 @@ fn image(s: &[usize]) -> Result<(usize, usize, usize)> {
|
||||
}
|
||||
}
|
||||
pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
|
||||
Ok(infer_all(g, input)?.1)
|
||||
}
|
||||
/// Each node's output shape, and the graph's outputs' shapes.
|
||||
pub(super) fn infer_all(g: &Graph, input: &[usize]) -> Result<(Vec<Vec<usize>>, Vec<Vec<usize>>)> {
|
||||
elements(input)?;
|
||||
let mut per_node = Vec::with_capacity(g.nodes.len());
|
||||
let mut shapes: HashMap<&str, Vec<usize>> = g.weights.iter().map(|(n, w)| (n.as_str(), w.dims.clone())).collect();
|
||||
shapes.insert(g.inputs.first().ok_or_else(|| NetError::Shape("no input".into()))?.0.as_str(), input.to_vec());
|
||||
let mut live = elements(input)?;
|
||||
@@ -480,6 +485,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
|
||||
.checked_add(out_count)
|
||||
.filter(|&n| n <= 128 * 1024 * 1024)
|
||||
.ok_or_else(|| NetError::Shape("live tensors exceed the 512 MiB working limit".into()))?;
|
||||
per_node.push(out.clone());
|
||||
shapes.insert(n.outputs.first().ok_or_else(|| NetError::Shape("no output".into()))?, out);
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
for name in &n.inputs {
|
||||
@@ -488,5 +494,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect()
|
||||
let outputs =
|
||||
g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect::<Result<_>>()?;
|
||||
Ok((per_node, outputs))
|
||||
}
|
||||
@@ -40,6 +40,9 @@ pub struct SelfTest {
|
||||
|
||||
type Plan = Net;
|
||||
|
||||
/// Most faces [`Embedder::embed_batch`] sends to a GPU in one go.
|
||||
pub const BATCH: usize = 16;
|
||||
|
||||
/// A loaded recognition model.
|
||||
#[derive(Clone)]
|
||||
pub struct Embedder {
|
||||
@@ -99,6 +102,31 @@ impl Embedder {
|
||||
})
|
||||
}
|
||||
|
||||
/// The network, e.g. to see which device it runs on.
|
||||
pub fn net(&self) -> &Net {
|
||||
&self.plan
|
||||
}
|
||||
|
||||
/// Run the model's convolutions on `accelerator` if it gives the CPU's embedding on a test face (and, with
|
||||
/// `require_faster`, runs it faster than the CPU); otherwise (or on
|
||||
/// its first error) recognition stays on the CPU. Returns why it was not used.
|
||||
pub fn accelerate(&self, accelerator: Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), RuntimeError> {
|
||||
let (mean, std) = (self.manifest.input.mean, self.manifest.input.std);
|
||||
let plane = self.width * self.height;
|
||||
let data: Vec<f32> = crate::yunet::probe_pixels(3 * plane)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let c = (i / plane.max(1)).min(2);
|
||||
(v - mean.get(c).copied().unwrap_or(0.0)) / std.get(c).copied().filter(|s| *s != 0.0).unwrap_or(1.0)
|
||||
})
|
||||
.collect();
|
||||
let probe = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?;
|
||||
guarded("checking the device", || {
|
||||
self.plan.attach_checked(accelerator, &probe, crate::yunet::ACCELERATOR_TOLERANCE, require_faster).map_err(RuntimeError::Run)
|
||||
})
|
||||
}
|
||||
|
||||
/// The size of the aligned face picture the model wants (width, height).
|
||||
pub fn input_size(&self) -> (usize, usize) {
|
||||
(self.width, self.height)
|
||||
@@ -110,6 +138,66 @@ impl Embedder {
|
||||
|
||||
/// The face as a unit-length vector. `rgb` is an aligned face at exactly [`Self::input_size`], 8-bit RGB.
|
||||
pub fn embed(&self, rgb: &[u8]) -> Result<Vec<f32>, RuntimeError> {
|
||||
let input = self.input(rgb)?;
|
||||
let out = guarded("running it", || {
|
||||
let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?;
|
||||
if result.len() != 1 {
|
||||
return Err(RuntimeError::Run("the model must give one embedding".into()));
|
||||
}
|
||||
Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data)
|
||||
})?;
|
||||
self.unit(out)
|
||||
}
|
||||
|
||||
/// Several faces at once, each as [`Self::embed`] gives it. On a GPU they run together, up to [`BATCH`] per
|
||||
/// dispatch (one upload and one read-back for all of them); on the CPU one after another.
|
||||
pub fn embed_batch(&self, faces: &[&[u8]]) -> Vec<Result<Vec<f32>, RuntimeError>> {
|
||||
if self.plan.accelerator().is_none() || faces.len() < 2 {
|
||||
return faces.iter().map(|f| self.embed(f)).collect();
|
||||
}
|
||||
let mut results: Vec<Option<Result<Vec<f32>, RuntimeError>>> = faces.iter().map(|_| None).collect();
|
||||
let mut ready: Vec<(usize, Tensor)> = Vec::new();
|
||||
for (i, face) in faces.iter().enumerate() {
|
||||
match self.input(face) {
|
||||
Ok(t) => ready.push((i, t)),
|
||||
Err(e) => {
|
||||
if let Some(r) = results.get_mut(i) {
|
||||
*r = Some(Err(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while !ready.is_empty() {
|
||||
let chunk: Vec<(usize, Tensor)> = ready.drain(..ready.len().min(BATCH)).collect();
|
||||
let (at, inputs): (Vec<usize>, Vec<Tensor>) = chunk.into_iter().unzip();
|
||||
let ran = guarded("running it", || self.plan.run_batch(inputs).map_err(|e| RuntimeError::Run(e.to_string())));
|
||||
match ran {
|
||||
Ok(outs) if outs.len() == at.len() => {
|
||||
for (i, mut out) in at.into_iter().zip(outs) {
|
||||
let r = match (out.len(), out.pop()) {
|
||||
(1, Some((_, t))) => self.unit(t.data),
|
||||
_ => Err(RuntimeError::Run("the model must give one embedding".into())),
|
||||
};
|
||||
if let Some(slot) = results.get_mut(i) {
|
||||
*slot = Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
// one bad face spoils a batch: run them one by one so the others still get theirs
|
||||
_ => {
|
||||
for i in at {
|
||||
if let (Some(slot), Some(face)) = (results.get_mut(i), faces.get(i)) {
|
||||
*slot = Some(self.embed(face));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
results.into_iter().map(|r| r.unwrap_or_else(|| Err(RuntimeError::Run("the face was not embedded".into())))).collect()
|
||||
}
|
||||
|
||||
/// The model's input for an aligned face.
|
||||
fn input(&self, rgb: &[u8]) -> Result<Tensor, RuntimeError> {
|
||||
if rgb.len() != self.width * self.height * 3 {
|
||||
return Err(RuntimeError::Input("the face picture is not the size the model wants".into()));
|
||||
}
|
||||
@@ -124,14 +212,11 @@ impl Embedder {
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = guarded("running it", || {
|
||||
let input = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?;
|
||||
let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?;
|
||||
if result.len() != 1 {
|
||||
return Err(RuntimeError::Run("the model must give one embedding".into()));
|
||||
}
|
||||
Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data)
|
||||
})?;
|
||||
Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))
|
||||
}
|
||||
|
||||
/// The model's output, checked and scaled to unit length.
|
||||
fn unit(&self, out: Vec<f32>) -> Result<Vec<f32>, RuntimeError> {
|
||||
if out.len() != self.dim {
|
||||
return Err(RuntimeError::Run(format!("the model gave {} numbers per face, not the {} its description says", out.len(), self.dim)));
|
||||
}
|
||||
|
||||
@@ -248,3 +248,63 @@ pub fn tiny_detector_manifest() -> crate::manifest::ModelManifest {
|
||||
m.size_bytes = Some(bytes.len() as u64);
|
||||
m
|
||||
}
|
||||
|
||||
/// A small network that uses every operator the interpreter runs (both kinds of convolution, batch norm, PRelu,
|
||||
/// sigmoid, broadcasting add/sub/mul with values and constants, max pooling, nearest upscaling, transposes, reshape,
|
||||
/// global pooling, flatten and a fully connected layer), with two outputs, for checking an accelerator's program
|
||||
/// against the CPU. Its input is `[1, 4, 8, 8]`.
|
||||
pub fn every_operator_graph() -> crate::graph::Graph {
|
||||
use crate::graph::{Attr, Data, Graph, Node, Weight};
|
||||
let node = |op: &str, inputs: &[&str], output: &str, attrs: &[(&str, Attr)]| Node {
|
||||
op: op.into(),
|
||||
inputs: inputs.iter().map(|s| s.to_string()).collect(),
|
||||
outputs: vec![output.into()],
|
||||
attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
|
||||
};
|
||||
let wave = |n: usize, k: usize| -> Vec<f32> { (0..n).map(|i| (((i * k) % 23) as f32 - 11.0) * 0.04).collect() };
|
||||
let w = |dims: &[usize], v: Vec<f32>| Weight { dims: dims.to_vec(), data: Data::F32(v) };
|
||||
let weights = [
|
||||
("w1", w(&[8, 4, 3, 3], wave(288, 7))),
|
||||
("b1", w(&[8], wave(8, 5))),
|
||||
("bn_s", w(&[8], (0..8).map(|i| 0.5 + i as f32 * 0.1).collect())),
|
||||
("bn_b", w(&[8], wave(8, 3))),
|
||||
("bn_m", w(&[8], wave(8, 11))),
|
||||
("bn_v", w(&[8], (0..8).map(|i| 0.2 + i as f32 * 0.05).collect())),
|
||||
("slope", w(&[8, 1, 1], (0..8).map(|i| 0.05 * i as f32).collect())),
|
||||
("w2", w(&[8, 1, 3, 3], wave(72, 13))),
|
||||
("scales", w(&[4], vec![1.0, 1.0, 2.0, 2.0])),
|
||||
("half", w(&[1], vec![0.5])),
|
||||
("per_channel", w(&[1, 8, 1, 1], wave(8, 17))),
|
||||
("wg", w(&[5, 8], wave(40, 19))),
|
||||
("bg", w(&[5], wave(5, 2))),
|
||||
("flat", Weight { dims: vec![2], data: Data::I64(vec![1, -1]) }),
|
||||
];
|
||||
let pads = |p: i64| ("pads", Attr::Ints(vec![p; 4]));
|
||||
let nodes = vec![
|
||||
node("Conv", &["x", "w1", "b1"], "c1", &[pads(1)]),
|
||||
node("BatchNormalization", &["c1", "bn_s", "bn_b", "bn_m", "bn_v"], "bn", &[]),
|
||||
node("PRelu", &["bn", "slope"], "pr", &[]),
|
||||
node("Conv", &["pr", "w2"], "c2", &[("group", Attr::Int(8)), ("strides", Attr::Ints(vec![2, 2])), pads(1)]),
|
||||
node("Sigmoid", &["c2"], "sg", &[]),
|
||||
node("Mul", &["c2", "sg"], "swish", &[]),
|
||||
node("Resize", &["swish", "", "scales"], "up", &[]),
|
||||
node("Add", &["up", "pr"], "sum", &[]),
|
||||
node("Sub", &["sum", "half"], "less", &[]),
|
||||
node("MaxPool", &["less"], "pool", &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))]),
|
||||
node("Relu", &["pool"], "relu", &[]),
|
||||
node("Transpose", &["relu"], "nhwc", &[("perm", Attr::Ints(vec![0, 2, 3, 1]))]),
|
||||
node("Transpose", &["nhwc"], "nchw", &[("perm", Attr::Ints(vec![0, 3, 1, 2]))]),
|
||||
node("Mul", &["nchw", "per_channel"], "scaled", &[]),
|
||||
node("Identity", &["scaled"], "same", &[]),
|
||||
node("GlobalAveragePool", &["same"], "avg", &[]),
|
||||
node("Flatten", &["avg"], "row", &[]),
|
||||
node("Reshape", &["row", "flat"], "row2", &[]),
|
||||
node("Gemm", &["row2", "wg", "bg"], "y", &[("transB", Attr::Int(1)), ("alpha", Attr::Float(0.5))]),
|
||||
];
|
||||
Graph {
|
||||
nodes,
|
||||
weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
|
||||
inputs: vec![("x".into(), vec![1, 4, 8, 8])],
|
||||
outputs: vec!["y".into(), "nhwc".into()],
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ pub struct Face {
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
}
|
||||
|
||||
/// How far an accelerator's outputs may stray from the CPU's on the test input, relative to each output's largest value.
|
||||
pub const ACCELERATOR_TOLERANCE: f32 = 1e-3;
|
||||
|
||||
/// A deterministic, photo-like test input (smooth gradients plus texture, in 0..255).
|
||||
pub fn probe_pixels(n: usize) -> Vec<f32> {
|
||||
(0..n).map(|i| ((i % 640) as f32 * 0.3 + ((i * 7919) % 97) as f32 + (i / 640 % 480) as f32 * 0.2) % 255.0).collect()
|
||||
}
|
||||
|
||||
pub struct Detector {
|
||||
net: Net,
|
||||
/// Side of the square network input.
|
||||
@@ -90,6 +98,20 @@ impl Detector {
|
||||
Ok(Detector { net, side })
|
||||
}
|
||||
|
||||
/// The network, e.g. to see which device it runs on.
|
||||
pub fn net(&self) -> &Net {
|
||||
&self.net
|
||||
}
|
||||
|
||||
/// Run the network's convolutions on `accelerator` if it gives the CPU's outputs on a test picture (and, with `require_faster`,
|
||||
/// runs it faster than the CPU); otherwise
|
||||
/// (or on its first error) detection stays on the CPU. Returns why it was not used.
|
||||
pub fn accelerate(&self, accelerator: std::sync::Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), String> {
|
||||
let side = self.side;
|
||||
let probe = Tensor::new(vec![1, 3, side, side], probe_pixels(3 * side * side)).map_err(|e| e.to_string())?;
|
||||
self.net.attach_checked(accelerator, &probe, ACCELERATOR_TOLERANCE, require_faster)
|
||||
}
|
||||
|
||||
/// The square input side, used to check an installed manifest.
|
||||
pub fn input_side(&self) -> usize {
|
||||
self.side
|
||||
|
||||
@@ -104,7 +104,8 @@ pub struct GeoNames {
|
||||
|
||||
fn cell(p: LatLon) -> (i32, i32) {
|
||||
// valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range
|
||||
(p.lat.floor() as i32, p.lon.floor() as i32)
|
||||
// longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes
|
||||
(p.lat.floor() as i32, (p.lon.floor() as i32 + 180).rem_euclid(360) - 180)
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
@@ -253,6 +254,14 @@ mod tests {
|
||||
assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn city_on_the_antimeridian_is_found() {
|
||||
let g = GeoNames::parse("1\tTaveuni\tTaveuni\t\t0.0\t180.0\tP\tPPL\tFJ\t\t01\n", None, None);
|
||||
for lon in [179.9, -179.9, 180.0, -180.0] {
|
||||
assert_eq!(g.reverse(LatLon::new(0.0, lon)).map(|p| p.city).as_deref(), Some("Taveuni"), "from {lon}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_latitude_cities_two_cells_away() {
|
||||
let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None);
|
||||
|
||||
@@ -14,6 +14,7 @@ lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,8 @@ pub mod backend;
|
||||
mod ctx;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod exit;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod faces;
|
||||
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
|
||||
pub mod nn;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -239,9 +241,9 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the
|
||||
/// Why GPU compute that has a device of its own (denoise, faces) is switched off by the user's settings, or `None`: the
|
||||
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
|
||||
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn switched_off() -> Option<String> {
|
||||
if env_disabled() {
|
||||
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Convolution for the face networks (lightcraft-faces), planar (NCHW) float32, batch 1, any stride, padding and grouping.
|
||||
//
|
||||
// `tiled` is an implicit matrix product per group: rows are the group's output channels (M), columns its output
|
||||
// pixels (N), and K runs over input channel x kernel tap. The input is gathered (im2col) straight into workgroup
|
||||
// memory, so the lowered matrix is never made. 256 threads make a 64 x 64 block, 4 x 4 values each.
|
||||
// `direct` gives one output value per thread, for depthwise convolutions (one output channel per group), where a
|
||||
// 64-row tile would be almost all waste.
|
||||
//
|
||||
// out[co][p] = bias[co] + sum_k W[co][k] * X[k][p] X[k][p] = input[g*cg + k/taps][oy*sh + ky - pt][ox*sw + kx - pl]
|
||||
|
||||
struct P {
|
||||
m: u32, // output channels per group
|
||||
n: u32, // output pixels (oh * ow)
|
||||
k: u32, // cg * kh * kw
|
||||
cg: u32, // input channels per group
|
||||
h: u32,
|
||||
w: u32,
|
||||
ow: u32,
|
||||
kh: u32,
|
||||
kw: u32,
|
||||
sh: u32,
|
||||
sw: u32,
|
||||
pt: u32,
|
||||
pl: u32,
|
||||
bias: u32, // 1 when there is a bias
|
||||
total: u32, // cout * n
|
||||
row: u32, // workgroups along x, for `direct` and `reduce` (so a large layer can use y too)
|
||||
splits: u32, // K is cut into this many slices (z = group * splits + slice), summed by `reduce`
|
||||
kchunk: u32, // K values per slice, a multiple of BK
|
||||
_a: u32,
|
||||
_b: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: P;
|
||||
@group(0) @binding(1) var<storage, read> src: array<f32>;
|
||||
@group(0) @binding(2) var<storage, read> wgt: array<f32>;
|
||||
@group(0) @binding(3) var<storage, read> bias: array<f32>;
|
||||
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
|
||||
|
||||
const BM: u32 = 64u;
|
||||
const BN: u32 = 64u;
|
||||
const BK: u32 = 16u;
|
||||
|
||||
var<workgroup> As: array<f32, 1024>; // [BK][BM]
|
||||
var<workgroup> Bs: array<f32, 1024>; // [BK][BN]
|
||||
|
||||
fn gather(g: u32, k: u32, pix: u32) -> f32 {
|
||||
let taps = p.kh * p.kw;
|
||||
let ci = g * p.cg + k / taps;
|
||||
let tap = k % taps;
|
||||
let oy = pix / p.ow;
|
||||
let ox = pix % p.ow;
|
||||
let iy = i32(oy * p.sh + tap / p.kw) - i32(p.pt);
|
||||
let ix = i32(ox * p.sw + tap % p.kw) - i32(p.pl);
|
||||
if (iy < 0 || ix < 0 || iy >= i32(p.h) || ix >= i32(p.w)) {
|
||||
return 0.0;
|
||||
}
|
||||
return src[(ci * p.h + u32(iy)) * p.w + u32(ix)];
|
||||
}
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn tiled(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
|
||||
let n0 = wg.x * BN;
|
||||
let m0 = wg.y * BM;
|
||||
let g = wg.z / p.splits;
|
||||
let slice = wg.z % p.splits;
|
||||
let k_lo = slice * p.kchunk;
|
||||
let k_hi = min(p.k, k_lo + p.kchunk);
|
||||
let tx = tid % 16u;
|
||||
let ty = tid / 16u;
|
||||
var acc: array<array<f32, 4>, 4>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) {
|
||||
acc[i][j] = 0.0;
|
||||
}
|
||||
}
|
||||
for (var k0 = k_lo; k0 < k_hi; k0 += BK) {
|
||||
// 1024 values of each tile, 4 per thread; every slot has exactly one writer
|
||||
for (var e = tid; e < 1024u; e += 256u) {
|
||||
// weights: neighbouring threads read neighbouring k of one row
|
||||
let mm = e / BK;
|
||||
let ka = k0 + e % BK;
|
||||
let m = m0 + mm;
|
||||
var a = 0.0;
|
||||
if (ka < k_hi && m < p.m) {
|
||||
a = wgt[(g * p.m + m) * p.k + ka];
|
||||
}
|
||||
As[(e % BK) * BM + mm] = a;
|
||||
// input: neighbouring threads read neighbouring pixels
|
||||
let kk = e / BN;
|
||||
let k = k0 + kk;
|
||||
let pix = n0 + e % BN;
|
||||
var b = 0.0;
|
||||
if (k < k_hi && pix < p.n) {
|
||||
b = gather(g, k, pix);
|
||||
}
|
||||
Bs[e] = b;
|
||||
}
|
||||
workgroupBarrier();
|
||||
for (var kk = 0u; kk < BK; kk++) {
|
||||
var a: array<f32, 4>;
|
||||
var b: array<f32, 4>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
a[i] = As[kk * BM + ty * 4u + i];
|
||||
b[i] = Bs[kk * BN + tx + 16u * i];
|
||||
}
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) {
|
||||
acc[i][j] += a[i] * b[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let m = m0 + ty * 4u + i;
|
||||
if (m >= p.m) {
|
||||
continue;
|
||||
}
|
||||
let co = g * p.m + m;
|
||||
var b0 = 0.0;
|
||||
if (p.bias != 0u && p.splits == 1u) {
|
||||
b0 = bias[co];
|
||||
}
|
||||
// with slices, each writes a partial sum of its own: [slice][cout][n]
|
||||
let base = slice * p.total + co * p.n;
|
||||
for (var j = 0u; j < 4u; j++) {
|
||||
// columns tx, tx + 16, tx + 32, tx + 48: neighbouring threads write neighbouring pixels
|
||||
let pix = n0 + tx + 16u * j;
|
||||
if (pix < p.n) {
|
||||
dst[base + pix] = acc[i][j] + b0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn direct(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
|
||||
let at = (wg.y * p.row + wg.x) * 256u + tid;
|
||||
if (at >= p.total) {
|
||||
return;
|
||||
}
|
||||
let co = at / p.n;
|
||||
let pix = at % p.n;
|
||||
let g = co / p.m;
|
||||
var v = 0.0;
|
||||
if (p.bias != 0u) {
|
||||
v = bias[co];
|
||||
}
|
||||
for (var k = 0u; k < p.k; k++) {
|
||||
v += wgt[co * p.k + k] * gather(g, k, pix);
|
||||
}
|
||||
dst[at] = v;
|
||||
}
|
||||
|
||||
// The sum of the K slices' partial results, plus the bias. `src` holds [splits][cout][n].
|
||||
@compute @workgroup_size(256)
|
||||
fn reduce(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
|
||||
let at = (wg.y * p.row + wg.x) * 256u + tid;
|
||||
if (at >= p.total) {
|
||||
return;
|
||||
}
|
||||
var v = 0.0;
|
||||
if (p.bias != 0u) {
|
||||
v = bias[at / p.n];
|
||||
}
|
||||
for (var s = 0u; s < p.splits; s++) {
|
||||
v += src[s * p.total + at];
|
||||
}
|
||||
dst[at] = v;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// The face networks' other operators (lightcraft-faces `net::program`), so activations stay on the device between layers.
|
||||
// Same bindings as faces_conv.wgsl: the parameters, three read-only operands and the output. Element kernels run one
|
||||
// thread per output value; `gavg` and `gemv` one workgroup per output value.
|
||||
//
|
||||
// Parameters are 24 numbers: q(0) = values (or workgroups) to make, q(1) = workgroups along x, then per kernel.
|
||||
|
||||
struct P {
|
||||
v: array<vec4<u32>, 6>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: P;
|
||||
@group(0) @binding(1) var<storage, read> a: array<f32>;
|
||||
@group(0) @binding(2) var<storage, read> b: array<f32>;
|
||||
@group(0) @binding(3) var<storage, read> c: array<f32>;
|
||||
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
|
||||
|
||||
fn q(i: u32) -> u32 {
|
||||
return p.v[i / 4u][i % 4u];
|
||||
}
|
||||
|
||||
fn index(wid: vec3<u32>, lid: u32) -> u32 {
|
||||
return (wid.y * q(1) + wid.x) * 256u + lid;
|
||||
}
|
||||
|
||||
// q(2): 0 relu, 1 sigmoid
|
||||
@compute @workgroup_size(256)
|
||||
fn unary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
let x = a[i];
|
||||
if (q(2) == 0u) {
|
||||
dst[i] = max(x, 0.0);
|
||||
} else {
|
||||
dst[i] = 1.0 / (1.0 + exp(-x));
|
||||
}
|
||||
}
|
||||
|
||||
// q(2) plane, q(3) channels: x * b[c] + c[c]
|
||||
@compute @workgroup_size(256)
|
||||
fn affine(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
let ch = (i / q(2)) % q(3);
|
||||
dst[i] = a[i] * b[ch] + c[ch];
|
||||
}
|
||||
|
||||
// q(2) plane, q(3) channels: x < 0 ? x * b[c] : x
|
||||
@compute @workgroup_size(256)
|
||||
fn prelu(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
let x = a[i];
|
||||
let ch = (i / q(2)) % q(3);
|
||||
dst[i] = select(x, x * b[ch], x < 0.0);
|
||||
}
|
||||
|
||||
// q(2): 0 add, 1 sub, 2 mul; q(3..7) output dims; q(7..11) a's strides; q(11..15) b's strides
|
||||
@compute @workgroup_size(256)
|
||||
fn binary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
var rest = i;
|
||||
var ia = 0u;
|
||||
var ib = 0u;
|
||||
for (var d = 3; d >= 0; d = d - 1) {
|
||||
let du = u32(d);
|
||||
let size = q(3u + du);
|
||||
let at = rest % size;
|
||||
rest = rest / size;
|
||||
ia = ia + at * q(7u + du);
|
||||
ib = ib + at * q(11u + du);
|
||||
}
|
||||
let x = a[ia];
|
||||
let y = b[ib];
|
||||
if (q(2) == 0u) {
|
||||
dst[i] = x + y;
|
||||
} else if (q(2) == 1u) {
|
||||
dst[i] = x - y;
|
||||
} else {
|
||||
dst[i] = x * y;
|
||||
}
|
||||
}
|
||||
|
||||
// q(3..7) output dims; q(7..11) the input's strides for each output axis
|
||||
@compute @workgroup_size(256)
|
||||
fn transpose(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
var rest = i;
|
||||
var at_in = 0u;
|
||||
for (var d = 3; d >= 0; d = d - 1) {
|
||||
let du = u32(d);
|
||||
let size = q(3u + du);
|
||||
at_in = at_in + (rest % size) * q(7u + du);
|
||||
rest = rest / size;
|
||||
}
|
||||
dst[i] = a[at_in];
|
||||
}
|
||||
|
||||
// q(2) h, q(3) w, q(4) kh, q(5) kw, q(6) sh, q(7) sw, q(8) oh, q(9) ow
|
||||
@compute @workgroup_size(256)
|
||||
fn maxpool(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
let h = q(2);
|
||||
let w = q(3);
|
||||
let oh = q(8);
|
||||
let ow = q(9);
|
||||
let ox = i % ow;
|
||||
let oy = (i / ow) % oh;
|
||||
let ch = i / (ow * oh);
|
||||
var m = -3.402823e38;
|
||||
for (var ky = 0u; ky < q(4); ky = ky + 1u) {
|
||||
for (var kx = 0u; kx < q(5); kx = kx + 1u) {
|
||||
let y = oy * q(6) + ky;
|
||||
let x = ox * q(7) + kx;
|
||||
if (y < h && x < w) {
|
||||
m = max(m, a[(ch * h + y) * w + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
dst[i] = m;
|
||||
}
|
||||
|
||||
// q(2) h, q(3) w, q(4) sh, q(5) sw: nearest upscale by whole numbers
|
||||
@compute @workgroup_size(256)
|
||||
fn resize(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let i = index(wid, lid);
|
||||
if (i >= q(0)) { return; }
|
||||
let h = q(2);
|
||||
let w = q(3);
|
||||
let ow = w * q(5);
|
||||
let oh = h * q(4);
|
||||
let ox = i % ow;
|
||||
let oy = (i / ow) % oh;
|
||||
let ch = i / (ow * oh);
|
||||
dst[i] = a[(ch * h + oy / q(4)) * w + ox / q(5)];
|
||||
}
|
||||
|
||||
var<workgroup> partial: array<f32, 256>;
|
||||
|
||||
fn reduce(lid: u32) {
|
||||
for (var s = 128u; s > 0u; s = s >> 1u) {
|
||||
workgroupBarrier();
|
||||
if (lid < s) {
|
||||
partial[lid] = partial[lid] + partial[lid + s];
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
|
||||
// one workgroup per channel; q(2) plane
|
||||
@compute @workgroup_size(256)
|
||||
fn gavg(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let ch = wid.y * q(1) + wid.x;
|
||||
let plane = q(2);
|
||||
var sum = 0.0;
|
||||
if (ch < q(0)) {
|
||||
for (var j = lid; j < plane; j = j + 256u) {
|
||||
sum = sum + a[ch * plane + j];
|
||||
}
|
||||
}
|
||||
partial[lid] = sum;
|
||||
reduce(lid);
|
||||
if (lid == 0u && ch < q(0)) {
|
||||
dst[ch] = partial[0] / f32(plane);
|
||||
}
|
||||
}
|
||||
|
||||
// one workgroup per output; q(2) k, q(3) n, q(4) 1 when B is [n][k], q(5) alpha's bits
|
||||
@compute @workgroup_size(256)
|
||||
fn gemv(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
|
||||
let o = wid.y * q(1) + wid.x;
|
||||
let k = q(2);
|
||||
let n = q(3);
|
||||
var sum = 0.0;
|
||||
if (o < q(0)) {
|
||||
for (var j = lid; j < k; j = j + 256u) {
|
||||
let wi = select(j * n + o, o * k + j, q(4) == 1u);
|
||||
sum = sum + a[j] * b[wi];
|
||||
}
|
||||
}
|
||||
partial[lid] = sum;
|
||||
reduce(lid);
|
||||
if (lid == 0u && o < q(0)) {
|
||||
dst[o] = bitcast<f32>(q(5)) * partial[0] + c[o];
|
||||
}
|
||||
}
|
||||
@@ -285,7 +285,7 @@
|
||||
"Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknutím na fotografii vyberete barvu; ⇧-kliknutím přidáte další (nejvýše 5)",
|
||||
"Click to clear these filters": "Kliknutím zrušíte tyto filtry",
|
||||
"Click to restore · double-click to rename": "Kliknutím obnovíte · dvojklikem přejmenujete",
|
||||
"Click to type a value": "Kliknutím můžete zadat hodnotu",
|
||||
"Click to type a value, drag to change it": "Kliknutím můžete zadat hodnotu, tažením ji změníte",
|
||||
"Clone": "Klonovat",
|
||||
"Close": "Zavřít",
|
||||
"Close Panel": "Zavřít panel",
|
||||
|
||||
@@ -1945,7 +1945,7 @@
|
||||
"commercial use subject to GPL conditions": "kommerzielle Nutzung unter GPL-Bedingungen",
|
||||
"Decrease Rating": "Bewertung verringern",
|
||||
"Increase Rating": "Bewertung erhöhen",
|
||||
"Click to type a value": "Klicken, um einen Wert einzugeben",
|
||||
"Click to type a value, drag to change it": "Klicken, um einen Wert einzugeben, ziehen, um ihn zu ändern",
|
||||
"Angle": "Winkel",
|
||||
"Video": "Video",
|
||||
"Copyrighted": "Urheberrechtlich geschützt",
|
||||
@@ -2332,5 +2332,14 @@
|
||||
"Animal Eye Tracking": "Tieraugen-Tracking",
|
||||
"Human Eye Tracking": "Augen-Tracking (Mensch)",
|
||||
"Read Focus Points": "Fokuspunkte lesen",
|
||||
"Reading focus points": "Fokuspunkte werden gelesen"
|
||||
"Reading focus points": "Fokuspunkte werden gelesen",
|
||||
"Click to type a value": "Klicken, um einen Wert einzugeben",
|
||||
"Run face models on the CPU only": "Gesichtsmodelle nur auf der CPU ausführen",
|
||||
"Detection": "Erkennung",
|
||||
"Recognition": "Wiedererkennung",
|
||||
"Not loaded yet": "Noch nicht geladen",
|
||||
"Checking the GPU…": "GPU wird geprüft …",
|
||||
"CPU": "CPU",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.",
|
||||
"Checking the GPU for faces": "GPU für Gesichter wird geprüft"
|
||||
}
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"Increase Rating": "Aumentar valoración",
|
||||
"Index Faces": "Indexar caras",
|
||||
"Index Faces in the Background": "Indexar caras en segundo plano",
|
||||
"Click to type a value": "Hacer clic para escribir un valor",
|
||||
"Click to type a value, drag to change it": "Hacer clic para escribir un valor, arrastrar para cambiarlo",
|
||||
"Angle": "Ángulo",
|
||||
"Video": "Vídeo",
|
||||
"Copyrighted": "Con copyright",
|
||||
@@ -2331,5 +2331,14 @@
|
||||
"Animal Eye Tracking": "Seguimiento de ojos de animales",
|
||||
"Human Eye Tracking": "Seguimiento de ojos humanos",
|
||||
"Read Focus Points": "Leer puntos de enfoque",
|
||||
"Reading focus points": "Leyendo puntos de enfoque"
|
||||
"Reading focus points": "Leyendo puntos de enfoque",
|
||||
"Click to type a value": "Hacer clic para escribir un valor",
|
||||
"Run face models on the CPU only": "Ejecutar los modelos de caras solo en la CPU",
|
||||
"Detection": "Detección",
|
||||
"Recognition": "Reconocimiento",
|
||||
"Not loaded yet": "Aún no cargado",
|
||||
"Checking the GPU…": "Comprobando la GPU…",
|
||||
"CPU": "CPU",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.",
|
||||
"Checking the GPU for faces": "Comprobando la GPU para las caras"
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Appliquer le mappage local des tons enregistré dans le fichier raw (Apple ProRAW), comme le rend l’appareil",
|
||||
"Decrease Rating": "Diminuer la note",
|
||||
"Increase Rating": "Augmenter la note",
|
||||
"Click to type a value": "Cliquez pour saisir une valeur",
|
||||
"Click to type a value, drag to change it": "Cliquez pour saisir une valeur, faites glisser pour la modifier",
|
||||
"Angle": "Angle",
|
||||
"Video": "Vidéo",
|
||||
"Copyrighted": "Protégé par copyright",
|
||||
@@ -2329,5 +2329,14 @@
|
||||
"Animal Eye Tracking": "Suivi des yeux d’animaux",
|
||||
"Human Eye Tracking": "Suivi des yeux humains",
|
||||
"Read Focus Points": "Lire les points de mise au point",
|
||||
"Reading focus points": "Lecture des points de mise au point"
|
||||
"Reading focus points": "Lecture des points de mise au point",
|
||||
"Click to type a value": "Cliquez pour saisir une valeur",
|
||||
"Run face models on the CPU only": "Exécuter les modèles de visages sur le processeur uniquement",
|
||||
"Detection": "Détection",
|
||||
"Recognition": "Reconnaissance",
|
||||
"Not loaded yet": "Pas encore chargé",
|
||||
"Checking the GPU…": "Vérification du GPU…",
|
||||
"CPU": "Processeur",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.",
|
||||
"Checking the GPU for faces": "Vérification du GPU pour les visages"
|
||||
}
|
||||
@@ -1789,7 +1789,7 @@
|
||||
"commercial use subject to GPL conditions": "商用利用は GPL の条件に従う",
|
||||
"Decrease Rating": "評価を下げる",
|
||||
"Increase Rating": "評価を上げる",
|
||||
"Click to type a value": "クリックして値を入力",
|
||||
"Click to type a value, drag to change it": "クリックして値を入力、ドラッグして変更",
|
||||
"Angle": "角度",
|
||||
"Video": "動画",
|
||||
"Copyrighted": "著作権あり",
|
||||
@@ -2331,5 +2331,14 @@
|
||||
"Animal Eye Tracking": "動物瞳トラッキング",
|
||||
"Human Eye Tracking": "人物瞳トラッキング",
|
||||
"Read Focus Points": "フォーカスポイントを読み込む",
|
||||
"Reading focus points": "フォーカスポイントを読み込み中"
|
||||
"Reading focus points": "フォーカスポイントを読み込み中",
|
||||
"Click to type a value": "クリックして値を入力",
|
||||
"Run face models on the CPU only": "顔モデルを CPU だけで実行",
|
||||
"Detection": "検出",
|
||||
"Recognition": "認識",
|
||||
"Not loaded yet": "未読み込み",
|
||||
"Checking the GPU…": "GPU を確認中…",
|
||||
"CPU": "CPU",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。",
|
||||
"Checking the GPU for faces": "顔用の GPU を確認中"
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
{
|
||||
" ({unsure} with several look-alike files: use Locate)": " ({unsure} რამდენიმე მსგავსი ფაილით: გამოიყენეთ „მოძებნა“)",
|
||||
" ({} in Recently Deleted)": " ({} ახლახან წაშლილებში)",
|
||||
" · {:.0}% clipped": " · {:.0}% მოკვეთილია",
|
||||
" · {failed} couldn't be rendered": " · ვერ დარენდერდა: {failed}",
|
||||
" · {queued} waiting": " · რიგშია: {queued}",
|
||||
" · {skipped} skipped (file exists)": " · გამოტოვებულია: {skipped} (ფაილი უკვე არსებობს)",
|
||||
" · {} duplicate{} skipped": " · გამოტოვებული დუბლიკატი: {}{:.0}",
|
||||
" · {} failed: {}": " · ვერ მოხერხდა: {}: {}",
|
||||
" · {} moved": " · გადატანილია: {}",
|
||||
" · {} not readable": " · ვერ იკითხება: {}",
|
||||
" · {} original{} left at the source": " · წყაროში დატოვებული ორიგინალი: {}{:.0}",
|
||||
" · {} restored from Recently Deleted": " · ახლახან წაშლილებიდან აღდგენილია: {}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One ვერ შემოიტანს სიას, სადაც საკვანძო სიტყვა შეიცავს ; , < ან > სიმბოლოს: {names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — გამოტოვებულია, რადგან საკვანძო სიტყვების სია მათ ვერ დაიტევს: {names}",
|
||||
" — not carried over: {}{}": " — არ გადავიდა: {}{}",
|
||||
"+{n} more": "+ კიდევ {n}",
|
||||
"+{} more": "+ კიდევ {}",
|
||||
", {failed} file{} not readable": ", ვერ იკითხება ფაილი: {failed}{:.0}",
|
||||
", {} label": ", იარლიყი: {}",
|
||||
", {} star{}": ", ვარსკვლავი: {}{:.0}",
|
||||
"1 task is still running: {names}. Quitting stops it.": "ჯერ კიდევ მიმდინარეობს 1 ამოცანა: {names}. გასვლა მას შეწყვეტს.",
|
||||
"; {kept} already had a location": "; მდებარეობა უკვე ჰქონდა: {kept}",
|
||||
"; {n} gained synonyms": "; სინონიმები დაემატა: {n}",
|
||||
"; {outside} outside its time range": "; მარშრუტის დროის დიაპაზონს გარეთაა: {outside}",
|
||||
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, კომიტი: {}, სტრიქონი: +{} / −{} (Δ {}), ბინარული ფაილი: +{} / −{}, {} – {}",
|
||||
"Add {local_n} to My Photos": "ჩემს ფოტოებში დამატება: {local_n}",
|
||||
"Add {} Photo{}": "ფოტოს დამატება: {}{:.0}",
|
||||
"Add {} Photo{}, restore {}": "ფოტოს დამატება: {}{:.0}, აღდგენა: {}",
|
||||
"Add “{}” to Favorites": "„{}“ რჩეულებში დამატება",
|
||||
"Added {n} keyword{}": "დაემატა საკვანძო სიტყვა: {n}{:.0}",
|
||||
"Added {n} photo{} to My Photos": "ჩემს ფოტოებს დაემატა ფოტო: {n}{:.0}",
|
||||
"Added {n} photo{} to “{}”": "„{1}“-ს დაემატა ფოტო: {n}{0:.0}",
|
||||
"Added {what} to {name}": "{what}: დაემატა {name}-ს",
|
||||
"Added “{keyword}” to {n} photo{}": "„{keyword}“ დაემატა ფოტოს: {n}{:.0}",
|
||||
"Adding photos… {} of {}": "ფოტოების დამატება… {} / {}",
|
||||
"All {n} selected photos move by the same amount.": "მონიშნული ფოტოები ({n}) ერთი და იმავე სიდიდით გადაინაცვლებს.",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "უკვე ბიბლიოთეკაშია, ახლახან წაშლილებში: მონიშნულია ფოტო: {}{:.0} · მარჯვენა დაწკაპუნება → აღდგენა, ან სამუდამოდ წაშლა თავიდან იმპორტისთვის",
|
||||
"Already in the library: {} photo{} selected in All Photos": "უკვე ბიბლიოთეკაშია: „ყველა ფოტოში“ მონიშნულია ფოტო: {}{:.0}",
|
||||
"Auto ({})": "ავტომატური ({})",
|
||||
"Auto Import: added {} photo{}": "ავტოიმპორტი: დაემატა ფოტო: {}{:.0}",
|
||||
"Auto Sync: edits apply to {n} photos": "ავტოსინქრონიზაცია: ცვლილებები ვრცელდება ფოტოებზე: {n}",
|
||||
"Automatic ({} MB)": "ავტომატური ({} MB)",
|
||||
"Build 1:1 Previews ({scope})": "1:1 წინასწარი ხედების შექმნა ({scope})",
|
||||
"Build Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების შექმნა ({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "სტანდარტული ზომის წინასწარი ხედების შექმნა ({scope})",
|
||||
"Building previews for {} photos…": "იქმნება წინასწარი ხედები ფოტოებისთვის: {}…",
|
||||
"Click {}:1": "დაწკაპუნება {}:1",
|
||||
"Couldn't open the editor: {e}": "რედაქტორი ვერ გაიხსნა: {e}",
|
||||
"Create Keyword Tag Inside “{name}”…": "საკვანძო სიტყვის შექმნა „{name}“-ში…",
|
||||
"Create {n} Virtual Copies": "ვირტუალური ასლების შექმნა: {n}",
|
||||
"Creates {} stacks from {} of {scope}": "შეიქმნება დასტა: {} — {}-დან ({scope})",
|
||||
"Decoder: {reason}": "დეკოდერი: {reason}",
|
||||
"Delete \"{label}\"": "„{label}“-ის წაშლა",
|
||||
"Delete \"{label}\" (and the mask)": "„{label}“-ის წაშლა (ნიღაბთან ერთად)",
|
||||
"Delete the keyword “{name}”?": "წაიშალოს საკვანძო სიტყვა „{name}“?",
|
||||
"Delete {n} Photos": "ფოტოების წაშლა: {n}",
|
||||
"Delete “{current}”": "„{current}“-ის წაშლა",
|
||||
"Denoising {name}: {done} of {total} tiles{}": "{name}-ის ხმაურის მოცილება: ფრაგმენტი {done} / {total}{}",
|
||||
"Discard Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების მოცილება ({scope})",
|
||||
"Done ({what}): {done} photo{plural}": "მზადაა ({what}): ფოტო: {done}{plural:.0}",
|
||||
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "ჩამოიტვირთება {host}-დან. მიღებისა და შემოწმების შემდეგ LightCraft მას დააყენებს, {after}.",
|
||||
"Duplicate \"{label}\"": "„{label}“-ის დუბლირება",
|
||||
"Example: {example}": "მაგალითი: {example}",
|
||||
"Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}",
|
||||
"Exported {n} photo{}": "ექსპორტირებულია ფოტო: {n}{:.0}",
|
||||
"Exported {ok} of {total} photo{}": "ექსპორტირებულია ფოტო: {ok} / {total}{:.0}",
|
||||
"Exported {} point curve preset{}": "ექსპორტირებულია წერტილოვანი მრუდის პრესეტი: {}{:.0}",
|
||||
"Exported {} preset{}": "ექსპორტირებულია პრესეტი: {}{:.0}",
|
||||
"Exporting {} of {total}": "ექსპორტი: {} / {total}",
|
||||
"Filter bar — {active} active filter{}": "ფილტრების ზოლი — აქტიური ფილტრი: {active}{:.0}",
|
||||
"Filtered: {label}": "გაფილტრულია: {label}",
|
||||
"Focus {:.0}": "ფოკუსი {:.0}",
|
||||
"Folder “{}” (and its subfolders)": "საქაღალდე „{}“ (ქვესაქაღალდეებით)",
|
||||
"Found {n} missing photo{}; {left} still missing{unsure}": "ნაპოვნია დაკარგული ფოტო: {n}{:.0}; კვლავ აკლია: {left}{unsure}",
|
||||
"Healed {n} dust spot{}": "აღდგენილია მტვრის ლაქა: {n}{:.0}",
|
||||
"Import cancelled · {} photo{} added": "იმპორტი გაუქმდა · დაემატა ფოტო: {}{:.0}",
|
||||
"Import failed: {e}": "იმპორტი ვერ მოხერხდა: {e}",
|
||||
"Import warnings ({n}):\n{warnings}": "იმპორტის გაფრთხილებები ({n}):\n{warnings}",
|
||||
"Imported {n} point curve preset{}": "იმპორტირებულია წერტილოვანი მრუდის პრესეტი: {n}{:.0}",
|
||||
"Imported {n} preset{}": "იმპორტირებულია პრესეტი: {n}{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "იმპორტირებულია პრესეტი: {n}{:.0}, ვერ იკითხება ფაილი: {f}{:.0}",
|
||||
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "იმპორტირებულია პროფილი: {profiles}{:.0} (პროფილების ბრაუზერი → მათი ჯგუფები)",
|
||||
"Imported {} photo{}": "იმპორტირებულია ფოტო: {}{:.0}",
|
||||
"In line for AI Denoise ({ahead} ahead)…": "AI ხმაურის მოცილების რიგში (წინ არის: {ahead})…",
|
||||
"Insert at the cursor (also written {})": "კურსორის ადგილას ჩასმა (ასევე იწერება როგორც {})",
|
||||
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "მოიხსნება ფოტოებიდან ({count}){:.0} მის ქვემოთ მყოფ საკვანძო სიტყვებთან ერთად. დაბრუნება მას აღადგენს.",
|
||||
"Label Set: {name}": "იარლიყების ნაკრები: {name}",
|
||||
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ჯერ ვერ დეკოდირებს RAW-ის ამ ვარიანტს ({}). თქვენ არედაქტირებთ კამერის ჩაშენებულ JPEG-ხედს, რომელიც უკვე შეიცავს კამერის გამოსახულების სტილს (მაგ., მონოქრომულს) და თეთრის ბალანსს.",
|
||||
"LightCraft could not open its window: {e}": "LightCraft-მა ფანჯარა ვერ გახსნა: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft-მა ფანჯარა ვერ გახსნა: {e}\n\nდეტალები ჟურნალის ფაილშია: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-ის კატალოგი შემოწმდა: ფოტო: {photos}, კოლექცია: {collections}, აკლია: {missing}",
|
||||
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Guided Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.",
|
||||
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.",
|
||||
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; შენახული გარდაქმნა იგნორირებულია, რადგან Upright გამორთულია.",
|
||||
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; {mode} ხელახლა გამოითვლება.",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}, ვერ მოხერხდა: {failed}",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}; ინდექსის გაფრთხილება: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom-იდან იმპორტი ვერ მოხერხდა: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom-ის შემოწმება ვერ მოხერხდა: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. შრიფტები: {} (OFL). ხატულები: ორიგინალური.",
|
||||
"Making the denoised picture… {}%": "იქმნება ხმაურმოცილებული სურათი… {}%",
|
||||
"Matches: {}": "დამთხვევა: {}",
|
||||
"Merging… {stage} {:.0}%": "გაერთიანება… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "გადავიდეს {what} ახლახან წაშლილებში?",
|
||||
"Moved {} photo(s)": "გადატანილია ფოტო: {}",
|
||||
"Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}",
|
||||
"Named ({named_n})": "სახელიანი ({named_n})",
|
||||
"New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში",
|
||||
"On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე",
|
||||
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“-ის მინიჭება: დაწკაპეთ ფოტოებზე მის დასამატებლად ან მოსახსნელად · Esc ასრულებს",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "ჩასმა ფოტოებში: {n}{:.0} — ჩაისმება მხოლოდ კოპირებული პარამეტრები",
|
||||
"Preset: {name}": "პრესეტი: {name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "მხოლოდ წინასწარი ხედი — რედაქტირდება კამერის ჩაშენებული JPEG ({})",
|
||||
"Preview only — {}": "მხოლოდ წინასწარი ხედი — {}",
|
||||
"Previews ready for {done} photo{plural}": "წინასწარი ხედები მზადაა ფოტოებისთვის: {done}{plural:.0}",
|
||||
"Profile: {}": "პროფილი: {}",
|
||||
"Proof Copy ({})": "საცდელი ასლი ({})",
|
||||
"Proof Preview · {}": "საცდელი ჩვენება · {}",
|
||||
"Put inside “{parent}”": "„{parent}“-ში მოთავსება",
|
||||
"Quick Develop · {n} photos": "სწრაფი დამუშავება · ფოტო: {n}",
|
||||
"Quick Develop: {name}": "სწრაფი დამუშავება: {name}",
|
||||
"Rated {}": "შეფასება: {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "იკითხება Lightroom-ის კატალოგი… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "იკითხება ფოტოები… {done} / {total}",
|
||||
"Reading photos… {} of {}": "იკითხება ფოტოები… {} / {}",
|
||||
"Redo {}": "{}-ის გამეორება",
|
||||
"Remove “{name}”": "„{name}“-ის მოშორება",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "მოშორდეს „{name}“ და მისი ფოტოები ({count}){:.0} ბიბლიოთეკიდან?",
|
||||
"Remove “{}” from Favorites": "„{}“ რჩეულებიდან ამოშლა",
|
||||
"Removed from {}": "ამოიშალა {}-დან",
|
||||
"Removed {what} from {name}": "{what}: ამოიშალა {name}-დან",
|
||||
"Rename {n} Photos…": "ფოტოების გადარქმევა: {n}…",
|
||||
"Rename “{name}”": "„{name}“-ის გადარქმევა",
|
||||
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "„{from}“ გადაერქმევა ფოტოებზე ({n}){:.0} (მის ქვემოთ მყოფ საკვანძო სიტყვებზეც). დონეებისთვის გამოიყენეთ |, მაგ., მოგზაურობა|იტალია. არსებული სახელის შემთხვევაში ორივე გაერთიანდება.",
|
||||
"Rendering on the CPU: {why}": "რენდერი CPU-ზე: {why}",
|
||||
"Replace {} with:": "{}-ის ჩანაცვლება:",
|
||||
"Reset {name}": "ჩამოყრა: {name}",
|
||||
"Reset {} Channel": "არხის ჩამოყრა: {}",
|
||||
"Restore {}": "აღდგენა: {}",
|
||||
"Restore {} Photo{}": "ფოტოს აღდგენა: {}{:.0}",
|
||||
"Restored {} photo{} from Recently Deleted": "ახლახან წაშლილებიდან აღდგა ფოტო: {}{:.0}",
|
||||
"Running on the processor: {}.": "მუშაობს პროცესორზე: {}.",
|
||||
"Running on {}.": "მუშაობს: {}.",
|
||||
"Saved export preset “{}”": "ექსპორტის პრესეტი „{}“ შენახულია",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "აფასებს ფოკუსსა და ექსპოზიციას ({scope}) და პოულობს ერთმანეთისგან რამდენიმე წამში გადაღებულ მსგავს კადრებს (სერიებს).",
|
||||
"Show {hidden} hidden location{}": "დამალული მდებარეობების ჩვენება: {hidden}{:.0}",
|
||||
"Showing {n} of {}": "ნაჩვენებია {n} / {}",
|
||||
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "აჩვენებს მოდელის პირობებს, შემდეგ ჩამოტვირთავს {}-ს {host}-დან, დააყენებს და დაიწყებს მის გამოყენებას. სხვა არაფერი იგზავნება.",
|
||||
"Smart album: {}": "სმარტ-ალბომი: {}",
|
||||
"Tagged {n} photo{} from the tracklog": "მარშრუტის ჟურნალით მოინიშნა ფოტო: {n}{:.0}",
|
||||
"The SAM 3 download failed: {e}": "SAM 3-ის ჩამოტვირთვა ვერ მოხერხდა: {e}",
|
||||
"There is a keyword set “{name}” already": "საკვანძო სიტყვების ნაკრები „{name}“ უკვე არსებობს",
|
||||
"There is a “{name}” at the top level already. Merge the two?": "ზედა დონეზე „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?",
|
||||
"Trained on: {provenance}": "გაწვრთნილია: {provenance}",
|
||||
"Undo {}": "დაბრუნება: {}",
|
||||
"Unknown tag{s} {} stay{} as typed (see Tags)": "უცნობი ტეგი{s:.0} {} რჩება როგორც აიკრიფა{:.0} (იხ. „ტეგები“)",
|
||||
"Updated “{name}”": "„{name}“ განახლდა",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "ვერსია {} — სუფთა ოთახის მეთოდით შექმნილი, სუფთა Rust-ზე დაწერილი ფოტობიბლიოთეკა და RAW-დამმუშავებელი.",
|
||||
"Version: {}": "ვერსია: {}",
|
||||
"Virtual copy “{name}” of {of}": "{of}-ის ვირტუალური ასლი „{name}“",
|
||||
"Working on {what} for {} photos…": "{what}: მუშავდება ფოტოები ({})…",
|
||||
"XMP adjustments were not imported for {path}: {fields}.": "{path}-ისთვის XMP-ის კორექციები არ შემოვიდა: {fields}.",
|
||||
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "{path}-ისთვის XMP პროფილი „{profile}“ არ გამოიყენება; ფერები შეიძლება განსხვავდებოდეს Lightroom-ისგან.",
|
||||
"none (CPU rendering): {why}": "არცერთი (რენდერი CPU-ზე): {why}",
|
||||
"not added to Apple Photos: {e}": "არ დაემატა Apple Photos-ს: {e}",
|
||||
"the {n} selected photos": "მონიშნული ფოტოები ({n})",
|
||||
"the {} photos in view": "ხედში არსებული ფოტოები ({})",
|
||||
"these {count} photos": "ეს ფოტოები ({count})",
|
||||
"{:.1} MB": "{:.1} MB",
|
||||
"{count} smart preview{} · {:.1} MB": "ჭკვიანი წინასწარი ხედი: {count}{:.0} · {:.1} MB",
|
||||
"{done} of {total}": "{done} / {total}",
|
||||
"{done} of {total} MB": "{done} / {total} MB",
|
||||
"{file} · {size}": "{file} · {size}",
|
||||
"{f} folders (and their subfolders)": "საქაღალდე: {f} (ქვესაქაღალდეებით)",
|
||||
"{label} Grading": "გრადაცია: {label}",
|
||||
"{label} {d:+} on every selected photo": "{label} {d:+} ყველა მონიშნულ ფოტოზე",
|
||||
"{label} {index} of {n}": "{label} {index} / {n}",
|
||||
"{label}, {n} photos": "{label}, ფოტო: {n}",
|
||||
"{matching} of {t} photos": "ფოტო: {matching} / {t}",
|
||||
"{matching} photos": "ფოტო: {matching}",
|
||||
"{mode} spot {} of {}": "{mode}: ლაქა {} / {}",
|
||||
"{month}": "{month:02}",
|
||||
"{month} {year}": "{month:02}.{year}",
|
||||
"{msg} ({failed} failed)": "{msg} (ვერ მოხერხდა: {failed})",
|
||||
"{name} Label": "იარლიყი: {name}",
|
||||
"{name} Off": "{name}: გამორთულია",
|
||||
"{name} On": "{name}: ჩართულია",
|
||||
"{name} opened for editing; it is stacked with the original": "{name} გაიხსნა რედაქტირებისთვის; ორიგინალთან ერთ დასტაშია",
|
||||
"{name} opened in the other editor": "{name} გაიხსნა სხვა რედაქტორში",
|
||||
"{name} · {terms}": "{name} · {terms}",
|
||||
"{name} — double-click to reset it": "{name} — ორმაგი დაწკაპუნებით ჩამოყრა",
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "გამოტოვებულია ლოკალური ფოტო: {n}{0:.0} — ალბომებში მხოლოდ ბიბლიოთეკის ფოტოებია (ჯერ დაამატეთ ჩემს ფოტოებში)",
|
||||
"{n} added to Apple Photos": "დაემატა Apple Photos-ს: {n}",
|
||||
"{n} added to Apple Photos album “{album}”": "დაემატა Apple Photos-ის ალბომს „{album}“: {n}",
|
||||
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "დისკზე ვერ ჩაიწერა ცვლილება: {n}{0:.0}: {e}\nთუ ახლა გახვალთ, ისინი დაიკარგება.{1:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "ცვლილება შენახულია მეხსიერებაში, მაგრამ დისკზე არ ჩაწერილა: {n}{:.0}: {e}\nLightCraft ავტომატურად ცდილობს ხელახლა; ახლა გასვლით ისინი დაიკარგება.{:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "ცვლილება შენახულია მეხსიერებაში, მაგრამ დისკზე არ ჩაწერილა: {n}{:.0}: {e} — LightCraft ხელახლა სცდის",
|
||||
"{n} found · {sel} selected": "ნაპოვნია: {n} · მონიშნულია: {sel}",
|
||||
"{n} originals opened in the other editor": "სხვა რედაქტორში გახსნილი ორიგინალები: {n}",
|
||||
"{n} photos": "ფოტო: {n}",
|
||||
"{n} photos in {} are hidden by the filters above": "ზემოთ მოცემული ფილტრები მალავს „{}“-ის ფოტოებს: {n}",
|
||||
"{n} photos opened for editing; each is stacked with its original": "რედაქტირებისთვის გაიხსნა ფოტო: {n}; თითოეული თავის ორიგინალთან ერთ დასტაშია",
|
||||
"{n} photo{}": "ფოტო: {n}{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "შესაბამისი ფოტო: {n}{:.0} · ავტომატურად განახლდება ფოტოების ცვლილებისას",
|
||||
"{n} photo{} now · updates automatically as photos change": "ახლა ფოტო: {n}{:.0} · ავტომატურად განახლდება ფოტოების ცვლილებისას",
|
||||
"{n} similar photo{} · View ▸ Clear Filters to see all": "მსგავსი ფოტო: {n}{:.0} · ყველას სანახავად: ხედი → ფილტრების გასუფთავება",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "ჯერ კიდევ მიმდინარეობს ამოცანები ({n}): {names}. გასვლა მათ შეწყვეტს.",
|
||||
"{n} {noun}": "{noun}: {n}",
|
||||
"{pct} %": "{pct} %",
|
||||
"{pid} (missing)": "{pid} (აკლია)",
|
||||
"{sel_n} selected · {counted}": "მონიშნულია: {sel_n} · {counted}",
|
||||
"{size} GB": "{size} GB",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{verb} {n} Photo{}": "{verb} (ფოტო: {n}){:.0}",
|
||||
"{weekday}, {day}": "{weekday}, {day}",
|
||||
"{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}",
|
||||
"{what} merge added": "{what}: გაერთიანება დაემატა",
|
||||
"{what} merge cancelled": "{what}: გაერთიანება გაუქმდა",
|
||||
"{what} merge failed: {e}": "{what}: გაერთიანება ვერ მოხერხდა: {e}",
|
||||
"{year}": "{year}",
|
||||
"{{date:…}} directives: {}": "{{date:…}} დირექტივები: {}",
|
||||
"{} KB": "{} KB",
|
||||
"{} MB": "{} MB",
|
||||
"{} and higher": "{} და მეტი",
|
||||
"{} contributors · {} commits": "მონაწილე: {} · კომიტი: {}",
|
||||
"{} failed": "ვერ მოხერხდა: {}",
|
||||
"{} files": "ფაილი: {}",
|
||||
"{} files and {f} folder{}": "ფაილი: {}, საქაღალდე: {f}{:.0}",
|
||||
"{} item{}": "ელემენტი: {}{:.0}",
|
||||
"{} label (click more labels to show any of them)": "იარლიყი „{}“ (დაწკაპეთ სხვა იარლიყებზე, რომ ნებისმიერი მათგანის მქონე ფოტო გამოჩნდეს)",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "დამხმარე ხაზი: {} / 4 — გაავლეთ ხაზებზე, რომლებიც ვერტიკალური ან ჰორიზონტალური უნდა იყოს.",
|
||||
"{} of {} from {host}": "{} / {} — {host}-დან",
|
||||
"{} photos": "ფოტო: {}",
|
||||
"{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV",
|
||||
"{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°",
|
||||
"{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}",
|
||||
"{} sample{}": "ნიმუში: {}{:.0}",
|
||||
"{} sec": "{} წმ",
|
||||
"{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}",
|
||||
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. ინახება ბიბლიოთეკის „denoise“ საქაღალდეში; ლიმიტის მიღწევისას პირველად იშლება ყველაზე ძველი, არცერთი ფოტოს მიერ გამოუყენებელი ფაილები.",
|
||||
"{} — show their photos": "{} — მისი ფოტოების ჩვენება",
|
||||
"· {dups} already in the library": "· უკვე ბიბლიოთეკაშია: {dups}",
|
||||
"· {in_source} identical copies": "· იდენტური ასლები: {in_source}",
|
||||
"· {n} photo{}": "· ფოტო: {n}{:.0}",
|
||||
"· {trashed} in Recently Deleted": "· ახლახან წაშლილებში: {trashed}",
|
||||
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“-ში „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?",
|
||||
"• {assumption}": "• {assumption}",
|
||||
"… and {more} more": "… და კიდევ {more}",
|
||||
" in {g} group{}": " in {g} group{}",
|
||||
" · kept, not applied: {}": " · kept, not applied: {}",
|
||||
" · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)",
|
||||
" — kept but not applied: {}": " — kept but not applied: {}",
|
||||
"Applied {name}": "Applied {name}",
|
||||
"Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos",
|
||||
"Create the keyword “{name}”": "Create the keyword “{name}”",
|
||||
"Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?",
|
||||
"Delete these {n} effect presets?": "Delete these {n} effect presets?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
|
||||
"Delete “{name}”?": "Delete “{name}”?",
|
||||
"Effect: {name}": "Effect: {name}",
|
||||
"Exported to {path}": "Exported to {path}",
|
||||
"Exported {} effect preset{}": "Exported {} effect preset{}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)",
|
||||
"Imported {name}": "Imported {name}",
|
||||
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
|
||||
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
|
||||
"Saved template {name}": "Saved template {name}",
|
||||
"Skipped {problem}": "Skipped {problem}",
|
||||
"Slideshow exported: {what}": "Slideshow exported: {what}",
|
||||
"Template preview: {name}": "Template preview: {name}",
|
||||
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
|
||||
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
|
||||
"Updated {name}": "Updated {name}",
|
||||
"new, inside {parent}": "new, inside {parent}",
|
||||
"{count} more warnings.": "{count} more warnings.",
|
||||
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
|
||||
"{n} JPEG files": "{n} JPEG files",
|
||||
"{n} pages": "{n} pages",
|
||||
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
|
||||
"{n} preset{}": "{n} preset{}",
|
||||
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
|
||||
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
|
||||
"{s} s of AV1 video": "{s} s of AV1 video",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
|
||||
"…and {more} more": "…and {more} more",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
|
||||
"Already in “{}”": "Already in “{}”",
|
||||
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
|
||||
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1745,7 +1745,7 @@
|
||||
"commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL",
|
||||
"Decrease Rating": "Zmniejsz ocenę",
|
||||
"Increase Rating": "Zwiększ ocenę",
|
||||
"Click to type a value": "Kliknij, aby wpisać wartość",
|
||||
"Click to type a value, drag to change it": "Kliknij, aby wpisać wartość, przeciągnij, aby ją zmienić",
|
||||
"Angle": "Kąt",
|
||||
"Video": "Wideo",
|
||||
"Copyrighted": "Chronione prawem autorskim",
|
||||
|
||||
@@ -1789,7 +1789,7 @@
|
||||
"commercial use subject to GPL conditions": "uso comercial sujeito às condições da GPL",
|
||||
"Decrease Rating": "Diminuir avaliação",
|
||||
"Increase Rating": "Aumentar avaliação",
|
||||
"Click to type a value": "Clique para digitar um valor",
|
||||
"Click to type a value, drag to change it": "Clique para digitar um valor, arraste para alterá-lo",
|
||||
"Angle": "Ângulo",
|
||||
"Video": "Vídeo",
|
||||
"Copyrighted": "Protegido por direitos autorais",
|
||||
@@ -2171,5 +2171,14 @@
|
||||
"Animal Eye Tracking": "Rastreamento de olhos de animais",
|
||||
"Human Eye Tracking": "Rastreamento de olhos humanos",
|
||||
"Read Focus Points": "Ler pontos de foco",
|
||||
"Reading focus points": "Lendo pontos de foco"
|
||||
"Reading focus points": "Lendo pontos de foco",
|
||||
"Click to type a value": "Clique para digitar um valor",
|
||||
"Run face models on the CPU only": "Executar os modelos de rostos só na CPU",
|
||||
"Detection": "Detecção",
|
||||
"Recognition": "Reconhecimento",
|
||||
"Not loaded yet": "Ainda não carregado",
|
||||
"Checking the GPU…": "Verificando a GPU…",
|
||||
"CPU": "CPU",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.",
|
||||
"Checking the GPU for faces": "Verificando a GPU para rostos"
|
||||
}
|
||||
@@ -1462,7 +1462,7 @@
|
||||
"commercial use subject to GPL conditions": "коммерческое использование на условиях GPL",
|
||||
"Decrease Rating": "Уменьшить оценку",
|
||||
"Increase Rating": "Повысить оценку",
|
||||
"Click to type a value": "Щёлкните, чтобы ввести значение",
|
||||
"Click to type a value, drag to change it": "Щёлкните, чтобы ввести значение, или перетащите, чтобы изменить",
|
||||
"Angle": "Угол",
|
||||
"Raw": "RAW",
|
||||
"Video": "Видео",
|
||||
@@ -2324,5 +2324,14 @@
|
||||
"Animal Eye Tracking": "Слежение за глазами животных",
|
||||
"Human Eye Tracking": "Слежение за глазами людей",
|
||||
"Read Focus Points": "Прочитать точки фокусировки",
|
||||
"Reading focus points": "Чтение точек фокусировки"
|
||||
"Reading focus points": "Чтение точек фокусировки",
|
||||
"Click to type a value": "Щёлкните, чтобы ввести значение",
|
||||
"Run face models on the CPU only": "Запускать модели лиц только на ЦП",
|
||||
"Detection": "Обнаружение",
|
||||
"Recognition": "Распознавание",
|
||||
"Not loaded yet": "Ещё не загружена",
|
||||
"Checking the GPU…": "Проверка ГП…",
|
||||
"CPU": "ЦП",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.",
|
||||
"Checking the GPU for faces": "Проверка ГП для лиц"
|
||||
}
|
||||
@@ -1973,7 +1973,7 @@
|
||||
"Recognition models": "Моделі розпізнавання",
|
||||
"Decrease Rating": "Зменшити оцінку",
|
||||
"Increase Rating": "Збільшити оцінку",
|
||||
"Click to type a value": "Клацніть, щоб ввести значення",
|
||||
"Click to type a value, drag to change it": "Клацніть, щоб ввести значення, або перетягніть, щоб змінити",
|
||||
"Angle": "Кут",
|
||||
"Video": "Відео",
|
||||
"Copyrighted": "Захищено авторським правом",
|
||||
@@ -2329,5 +2329,14 @@
|
||||
"Animal Eye Tracking": "Стеження за очима тварин",
|
||||
"Human Eye Tracking": "Стеження за очима людей",
|
||||
"Read Focus Points": "Прочитати точки фокусування",
|
||||
"Reading focus points": "Читання точок фокусування"
|
||||
"Reading focus points": "Читання точок фокусування",
|
||||
"Click to type a value": "Клацніть, щоб ввести значення",
|
||||
"Run face models on the CPU only": "Запускати моделі облич лише на ЦП",
|
||||
"Detection": "Виявлення",
|
||||
"Recognition": "Розпізнавання",
|
||||
"Not loaded yet": "Ще не завантажено",
|
||||
"Checking the GPU…": "Перевірка ГП…",
|
||||
"CPU": "ЦП",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.",
|
||||
"Checking the GPU for faces": "Перевірка ГП для облич"
|
||||
}
|
||||
@@ -1789,7 +1789,7 @@
|
||||
"commercial use subject to GPL conditions": "商用须遵循 GPL 条款",
|
||||
"Decrease Rating": "降低星级",
|
||||
"Increase Rating": "提高星级",
|
||||
"Click to type a value": "单击以输入数值",
|
||||
"Click to type a value, drag to change it": "单击以输入数值,拖动以更改",
|
||||
"Angle": "角度",
|
||||
"Video": "视频",
|
||||
"Copyrighted": "受版权保护",
|
||||
@@ -2170,5 +2170,14 @@
|
||||
"Animal Eye Tracking": "动物眼部追踪",
|
||||
"Human Eye Tracking": "人眼追踪",
|
||||
"Read Focus Points": "读取对焦点",
|
||||
"Reading focus points": "正在读取对焦点"
|
||||
"Reading focus points": "正在读取对焦点",
|
||||
"Click to type a value": "单击以输入数值",
|
||||
"Run face models on the CPU only": "仅在 CPU 上运行人脸模型",
|
||||
"Detection": "检测",
|
||||
"Recognition": "识别",
|
||||
"Not loaded yet": "尚未加载",
|
||||
"Checking the GPU…": "正在检查 GPU…",
|
||||
"CPU": "CPU",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。",
|
||||
"Checking the GPU for faces": "正在检查用于人脸的 GPU"
|
||||
}
|
||||
@@ -1789,7 +1789,7 @@
|
||||
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
|
||||
"Decrease Rating": "降低評分",
|
||||
"Increase Rating": "提高評分",
|
||||
"Click to type a value": "點擊以輸入數值",
|
||||
"Click to type a value, drag to change it": "點擊以輸入數值,拖曳以變更",
|
||||
"Angle": "角度",
|
||||
"Video": "影片",
|
||||
"Copyrighted": "受著作權保護",
|
||||
@@ -2132,5 +2132,14 @@
|
||||
"Animal Eye Tracking": "動物眼部追蹤",
|
||||
"Human Eye Tracking": "人眼追蹤",
|
||||
"Read Focus Points": "讀取對焦點",
|
||||
"Reading focus points": "正在讀取對焦點"
|
||||
"Reading focus points": "正在讀取對焦點",
|
||||
"Click to type a value": "點擊以輸入數值",
|
||||
"Run face models on the CPU only": "僅在 CPU 上執行臉孔模型",
|
||||
"Detection": "偵測",
|
||||
"Recognition": "辨識",
|
||||
"Not loaded yet": "尚未載入",
|
||||
"Checking the GPU…": "正在檢查 GPU…",
|
||||
"CPU": "CPU",
|
||||
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。",
|
||||
"Checking the GPU for faces": "正在檢查用於臉孔的 GPU"
|
||||
}
|
||||
@@ -129,6 +129,7 @@ language_table! {
|
||||
Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json");
|
||||
Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json");
|
||||
It, "it", "Italiano", "Latn", include_str!("../locales/it.json");
|
||||
Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json");
|
||||
}
|
||||
|
||||
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
|
||||
@@ -226,6 +227,22 @@ pub fn stopped_message(error: &str, log_file: Option<&str>) -> (String, String)
|
||||
(tr("LightCraft stopped").to_string(), text)
|
||||
}
|
||||
|
||||
/// Whether sentence case may upper-case `c`. Georgian has no case: upper-casing Mkhedruli would
|
||||
/// turn it into Mtavruli, a different (all-caps) script, so Georgian letters stay as written.
|
||||
pub fn capitalizes(c: char) -> bool {
|
||||
!matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}')
|
||||
}
|
||||
|
||||
/// `text` with its first letter upper-cased ("its input" → "Its input"), unless [`capitalizes`]
|
||||
/// says that letter has no capital to take.
|
||||
pub fn upper_first(text: &str) -> String {
|
||||
let mut chars = text.chars();
|
||||
match chars.next() {
|
||||
Some(first) if capitalizes(first) => first.to_uppercase().chain(chars).collect(),
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_label(source: &str, builtin: bool) -> &str {
|
||||
if builtin { tr(source) } else { source }
|
||||
}
|
||||
@@ -517,6 +534,15 @@ mod tests {
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_keeps_georgian_lowercase() {
|
||||
assert_eq!(upper_first("its input is not an image"), "Its input is not an image");
|
||||
assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება");
|
||||
assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ");
|
||||
assert_eq!(upper_first(""), "");
|
||||
assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_language_has_a_catalog_covering_the_source() {
|
||||
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
|
||||
@@ -755,6 +781,7 @@ mod tests {
|
||||
("app.language.polish", Locale::Pl),
|
||||
("app.language.czech", Locale::Cs),
|
||||
("app.language.italian", Locale::It),
|
||||
("app.language.georgian", Locale::Ka),
|
||||
];
|
||||
// One command per language, and every command reachable from the menu table.
|
||||
assert_eq!(commands.len(), Locale::ALL.len());
|
||||
@@ -981,6 +1008,31 @@ mod tests {
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn georgian_switches_formats_counts_and_dates() {
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.run("app.language.georgian", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.ui.language, Locale::Ka);
|
||||
assert_eq!(crate::menubar::checked(&app, "app.language.georgian"), Some(true));
|
||||
assert_eq!(tr("Light"), "სინათლე");
|
||||
assert_eq!(date_group_label("2026-09-20", false), "კვირა, 20.09.2026");
|
||||
assert_eq!(date_group_label("2026-09", false), "09.2026");
|
||||
for n in [0, 1, 2, 5, 21] {
|
||||
assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("იმპორტირებულია ფოტო: {n}"));
|
||||
}
|
||||
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Trip", n = 3), "„Trip“-ს დაემატა ფოტო: 3");
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
assert!(saved.contains(r#""language":"ka""#));
|
||||
for code in ["ka", "ka-GE", "ka_GE.UTF-8"] {
|
||||
assert_eq!(Locale::parse_tag(code), Some(Locale::Ka));
|
||||
}
|
||||
// no Mtavruli: Georgian UI text is written in lowercase Mkhedruli
|
||||
for text in Locale::Ka.catalog().values() {
|
||||
assert!(!text.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "{text}");
|
||||
}
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() {
|
||||
let ctx = fonts_ctx(&[]);
|
||||
|
||||
@@ -152,6 +152,8 @@ mod tests_sam_setup;
|
||||
#[cfg(test)]
|
||||
mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_slider_reset;
|
||||
#[cfg(test)]
|
||||
mod tests_slideshow;
|
||||
#[cfg(test)]
|
||||
mod tests_solo_sections;
|
||||
|
||||
@@ -233,7 +233,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) ->
|
||||
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<_>>(),
|
||||
"missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::<Vec<_>>(),
|
||||
"warnings": data.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
+24
-12
@@ -27,6 +27,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"),
|
||||
("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"),
|
||||
("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"),
|
||||
("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"),
|
||||
];
|
||||
|
||||
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
|
||||
@@ -52,6 +53,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
|
||||
"app.language.polish" => Some(crate::i18n::Locale::Pl),
|
||||
"app.language.czech" => Some(crate::i18n::Locale::Cs),
|
||||
"app.language.italian" => Some(crate::i18n::Locale::It),
|
||||
"app.language.georgian" => Some(crate::i18n::Locale::Ka),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2142,12 +2144,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
"app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(),
|
||||
"dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(),
|
||||
"photo.openOriginalInExternal" => {
|
||||
// only a file type LightCraft reads can be handed over (#2135); the extension alone
|
||||
// decides, so no disk or share is touched while a menu is drawn
|
||||
app.services.open_with.is_some()
|
||||
&& app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|id| app.session.catalog.photo(id))
|
||||
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
|
||||
&& app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source {
|
||||
lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
"app.showInFinder" => {
|
||||
app.services.reveal.is_some()
|
||||
@@ -2322,8 +2325,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft:
|
||||
if let Some(id) = id {
|
||||
app.ui.external_edits.push(*id);
|
||||
}
|
||||
if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) {
|
||||
failed = Some(e);
|
||||
// the copy is LightCraft's own render, but it goes through the same gate as any other
|
||||
// path handed to the system opener (#2135)
|
||||
match lightcraft_engine::open_path::openable(path, "") {
|
||||
Err(e) => failed = Some(e),
|
||||
Ok(absolute) => {
|
||||
if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) {
|
||||
failed = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match (failed, copies.as_slice()) {
|
||||
@@ -2375,11 +2385,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let mut files = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for id in ids {
|
||||
match app.session.catalog.photo(id).map(|ph| &ph.source) {
|
||||
Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())),
|
||||
Some(lightcraft_engine::catalog::Source::File { path }) => {
|
||||
skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")}))
|
||||
}
|
||||
// the path may come from an imported catalog: only a photo file LightCraft reads is
|
||||
// handed to the opener, never a program (#2135)
|
||||
match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) {
|
||||
Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) {
|
||||
Ok(absolute) => files.push((id, absolute)),
|
||||
Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})),
|
||||
},
|
||||
Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})),
|
||||
None => skipped.push(json!({"id": id.0, "reason": "no such photo"})),
|
||||
}
|
||||
|
||||
@@ -606,9 +606,7 @@ pub(super) fn mb(bytes: Option<u64>) -> String {
|
||||
|
||||
pub(super) fn sentence(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
let mut c = s.chars();
|
||||
let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default();
|
||||
out.push_str(c.as_str());
|
||||
let mut out = crate::i18n::upper_first(s);
|
||||
if !out.is_empty() && !out.ends_with(['.', '!', '?']) {
|
||||
out.push('.');
|
||||
}
|
||||
|
||||
@@ -402,12 +402,16 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt
|
||||
let job = if c.interacting { job.draft() } else { job };
|
||||
let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay };
|
||||
let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) };
|
||||
let job = job.with_overlay(overlay);
|
||||
// the After window is proofed like the whole-frame render under it; the Before side is
|
||||
// never proofed (its whole-frame render isn't either)
|
||||
let proof = app.ui.soft_proof.then_some(app.ui.proof);
|
||||
let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof });
|
||||
// keyed as the renderer keys the texture (a display profile changes the key)
|
||||
let job = app.renderer.job_for(v.slot, job);
|
||||
// The overlay is part of the tile's identity too, so changing mask view/colour never leaves a
|
||||
// stale region tile on screen while its replacement renders.
|
||||
let look = job.settings.hash64() ^ overlay.key().rotate_left(17);
|
||||
// The overlay and the proof are part of the tile's identity too, so changing mask view/colour
|
||||
// or the proof never leaves a stale region tile on screen while its replacement renders.
|
||||
let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) };
|
||||
let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41);
|
||||
let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look };
|
||||
// windows of other photos, looks and zooms of this side can't be drawn any more
|
||||
app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting));
|
||||
@@ -533,15 +537,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
|
||||
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
|
||||
let look = d.hash64();
|
||||
// Diagnostic views that need whole-frame context, soft proofing, or reads too far for one
|
||||
// window still render the whole frame at the size it is drawn. A mask overlay is window-safe.
|
||||
// Diagnostic views that need whole-frame context, or reads too far for one window, still
|
||||
// render the whole frame at the size it is drawn. Mask overlays and soft proofing (both act
|
||||
// pixel by pixel) are window-safe.
|
||||
let sizes = crate::region::ViewSizes {
|
||||
drawn_long,
|
||||
canvas_long: canvas.width().max(canvas.height()) * ppp,
|
||||
native_long,
|
||||
texture_side,
|
||||
draft,
|
||||
windows: !app.ui.soft_proof && overlay_supports_window(overlay),
|
||||
windows: overlay_supports_window(overlay),
|
||||
};
|
||||
let mut plan = crate::region::plan(&app.ui.settings, sizes);
|
||||
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
|
||||
@@ -623,10 +628,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
app.renderer.request(Slot::Before, job, 90);
|
||||
}
|
||||
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
|
||||
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use
|
||||
// the same window; diagnostic overlays that need whole-frame context do not.
|
||||
let windowable_view =
|
||||
hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
|
||||
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and
|
||||
// soft proofing use the same window; diagnostic overlays that need whole-frame context do not.
|
||||
let windowable_view = hover_key.is_none() && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
|
||||
let window_ctx = WindowCtx {
|
||||
id,
|
||||
overlay,
|
||||
@@ -2170,11 +2174,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
||||
app.brush_live = None;
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
// holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing
|
||||
let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt);
|
||||
let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift));
|
||||
let erase = app.ui.brush_erase != alt;
|
||||
// a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom
|
||||
let connect = shift && resp.clicked() && pts.len() == 1;
|
||||
let _ = app.run(
|
||||
"mask.brushStroke",
|
||||
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
|
||||
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure}),
|
||||
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -164,6 +164,42 @@ pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Where the face models run (the GPU only once a check shows it gives the CPU's results faster), and the switch that
|
||||
/// keeps them on the CPU.
|
||||
fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Value) {
|
||||
heading(ui, t, "Processor");
|
||||
let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false);
|
||||
let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only"));
|
||||
register(ui.ctx(), "check:settings.facesCpuOnly", r.rect);
|
||||
if r.changed() {
|
||||
match app.run("faces.gpu", json!({"cpuOnly": cpu})) {
|
||||
Ok(_) => app.caches.faces_epoch += 1,
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
for (what, label) in [("detection", "Detection"), ("recognition", "Recognition")] {
|
||||
let st = &gpu[what];
|
||||
let (text, why) = if st.is_null() {
|
||||
(crate::i18n::tr("Not loaded yet").to_string(), None)
|
||||
} else if st["checking"] == true {
|
||||
(crate::i18n::tr("Checking the GPU…").to_string(), None)
|
||||
} else if let Some(device) = st["device"].as_str() {
|
||||
(device.to_string(), None)
|
||||
} else {
|
||||
(crate::i18n::tr("CPU").to_string(), st["why"].as_str())
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim));
|
||||
let r = ui.label(text);
|
||||
register(ui.ctx(), format!("faces:device:{what}"), r.rect);
|
||||
if let Some(why) = why {
|
||||
r.on_hover_text(sentence(why));
|
||||
}
|
||||
});
|
||||
}
|
||||
hint(ui, t, "A face model uses the GPU only when a check shows it gives the same results faster.");
|
||||
}
|
||||
|
||||
/// A download-style size: decimal megabytes, as file managers and model pages show them.
|
||||
fn mb(bytes: Option<u64>) -> String {
|
||||
match bytes {
|
||||
@@ -177,9 +213,7 @@ fn mb(bytes: Option<u64>) -> String {
|
||||
/// "its input is not an image" → "Its input is not an image."
|
||||
fn sentence(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
let mut c = s.chars();
|
||||
let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default();
|
||||
out.push_str(c.as_str());
|
||||
let mut out = crate::i18n::upper_first(s);
|
||||
if !out.is_empty() && !out.ends_with(['.', '!', '?']) {
|
||||
out.push('.');
|
||||
}
|
||||
@@ -238,6 +272,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default();
|
||||
let downloads =
|
||||
app.session.execute("faces.models.downloads", &json!({})).map(|v| v["downloads"].as_array().cloned().unwrap_or_default()).unwrap_or_default();
|
||||
gpu_section(app, ui, t, &list["gpu"]);
|
||||
heading(ui, t, "Face detector");
|
||||
for m in all.iter().filter(|m| m["role"] == "detector") {
|
||||
model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime);
|
||||
|
||||
@@ -621,13 +621,13 @@ mod tests {
|
||||
"Export Keywords…",
|
||||
];
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
|
||||
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ja", "zh-hans", "zh-hant", "pl", "cs"] {
|
||||
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs"] {
|
||||
let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap();
|
||||
let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap();
|
||||
for label in labels {
|
||||
assert!(catalog.contains_key(label), "{code}: “{label}”");
|
||||
}
|
||||
}
|
||||
assert_eq!(Locale::ALL.len(), 13, "a language added: list its file above");
|
||||
assert_eq!(Locale::ALL.len(), 14, "a language added: list its file above");
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options:
|
||||
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
row(ui, t, crate::i18n::tr("Language"), |ui| {
|
||||
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
|
||||
// Twelve language names do not fit one line in a narrow window: wrap them.
|
||||
// Thirteen language names do not fit one line in a narrow window: wrap them.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
|
||||
});
|
||||
|
||||
@@ -77,8 +77,8 @@ pub struct ViewSizes {
|
||||
/// `None`: no drag.
|
||||
pub draft: Option<f32>,
|
||||
/// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay
|
||||
/// that needs whole-frame context, soft proofing, or a read too far for one window). Mask
|
||||
/// overlays are window-safe.
|
||||
/// that needs whole-frame context, or a read too far for one window). Mask
|
||||
/// overlays and soft proofing are window-safe.
|
||||
pub windows: bool,
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
|
||||
let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long };
|
||||
let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.clamp(0.1, 1.0) } else { 1.0 });
|
||||
if !v.windows {
|
||||
// the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no
|
||||
// the whole frame at the size it is drawn (a whole-frame diagnostic has no
|
||||
// window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must
|
||||
// not render the photo's every pixel each frame
|
||||
let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long };
|
||||
@@ -422,7 +422,7 @@ mod tests {
|
||||
assert_eq!(drag.window_edge, still.window_edge);
|
||||
}
|
||||
|
||||
// Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the
|
||||
// Given no window (a whole-frame diagnostic), the view at rest renders the
|
||||
// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the
|
||||
// windowed path
|
||||
#[test]
|
||||
@@ -626,7 +626,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a
|
||||
// Given a mode whose picture can't be a window (a whole-frame diagnostic, or a
|
||||
// spot read too far for one render), the whole-frame render is as big as the view
|
||||
#[test]
|
||||
fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() {
|
||||
|
||||
@@ -99,7 +99,7 @@ mod tests {
|
||||
fn run_dialog(h: &mut crate::headless::Headless) -> Value {
|
||||
let dialog = h.app.ui.dialog.take().unwrap();
|
||||
crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
let mut seen_row = false;
|
||||
loop {
|
||||
h.step();
|
||||
|
||||
@@ -121,7 +121,7 @@ fn the_straighten_value_says_it_can_be_typed() {
|
||||
// tooltips wait for the pointer to rest
|
||||
let _ = h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t.contains("type")));
|
||||
assert!(
|
||||
h.painted_text().iter().any(|t| t == "Click to type a value"),
|
||||
h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"),
|
||||
"{:?}",
|
||||
h.painted_text().iter().filter(|t| t.contains("lick")).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
@@ -343,6 +343,32 @@ fn holding_the_brush_still_does_not_harden_the_spot() {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// A ⇧-click with the brush paints a straight line from where the last stroke ended (issue #106 of the Classic
|
||||
/// fork), one undo step; a plain click only paints a dab where it lands.
|
||||
#[test]
|
||||
fn shift_click_paints_a_straight_line_from_the_last_stroke() {
|
||||
let mut h = detail("panel.masking");
|
||||
exec(&mut h, "tool.brush", json!({}));
|
||||
brush_at(&mut h, 0.2, 0.5);
|
||||
brush_at(&mut h, 0.8, 0.5);
|
||||
assert!(mask_coverage(&h, 0, 50, 50) < 0.01, "two plain clicks leave the space between them alone");
|
||||
let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.5, "y": 0.2}, {"kind": "up", "x": 0.5, "y": 0.2}], "shift": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let d = develop(&h);
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
assert_eq!(strokes.len(), 3, "{strokes:?}");
|
||||
assert_eq!(strokes[2].points.len(), 2, "from the last stroke's end to the click: {strokes:?}");
|
||||
for (x, y) in [(65, 35), (58, 27)] {
|
||||
assert!(mask_coverage(&h, 0, x, y) > 0.5, "the line from (0.8, 0.5) to (0.5, 0.2) passes ({x}, {y})");
|
||||
}
|
||||
assert!(mask_coverage(&h, 0, 35, 35) < 0.01, "and nowhere else");
|
||||
exec(&mut h, "edit.undo", json!({}));
|
||||
let d = develop(&h);
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
assert_eq!(strokes.len(), 2, "one undo step takes the line back");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_carry_auto_mask() {
|
||||
let mut h = detail("panel.masking");
|
||||
@@ -1151,6 +1177,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the
|
||||
/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is
|
||||
/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling.
|
||||
#[test]
|
||||
fn open_original_refuses_anything_that_is_not_a_photo_file() {
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let opened: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = Default::default();
|
||||
let batches: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>> = Default::default();
|
||||
let (o, bt) = (opened.clone(), batches.clone());
|
||||
let services = Services {
|
||||
png: None,
|
||||
open_with: Some(Box::new(move |path: &str, app: &str| {
|
||||
o.lock().unwrap().push((path.to_string(), app.to_string()));
|
||||
Ok(())
|
||||
})),
|
||||
open_all_with: Some(Box::new(move |paths: &[String], _: &str| {
|
||||
bt.lock().unwrap().push(paths.to_vec());
|
||||
Ok(())
|
||||
})),
|
||||
check_editor: Some(Box::new(|_: &str| Ok(()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = lightcraft_engine::Session::with_demo().with_fs();
|
||||
let mut record = |name: &str| {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, b"MZ").unwrap();
|
||||
let id = session.catalog.alloc_photo_id();
|
||||
let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00");
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
// what a crafted catalog can name as a photo's original
|
||||
let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"];
|
||||
let bad: Vec<_> = programs.iter().map(|n| record(n)).collect();
|
||||
let good = record("DSC_0001.NEF");
|
||||
let app = LightcraftApp::new(session, services);
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.app.ui.settings.external_editor = "DxO PhotoLab".into();
|
||||
|
||||
for (id, name) in bad.iter().zip(programs) {
|
||||
let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T);
|
||||
assert_eq!(r["ok"], false, "{name} was opened: {r}");
|
||||
assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}");
|
||||
// the menu item is greyed out for such a record, so it is never offered either
|
||||
exec(&mut h, "library.select", json!({"ids": [id.0]}));
|
||||
assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu");
|
||||
}
|
||||
assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program");
|
||||
|
||||
// a program selected together with a photo: only the photo is handed over, by absolute path
|
||||
let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]}));
|
||||
assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert!(r["skipped"].to_string().contains("payload.exe"), "{r}");
|
||||
let handed = batches.lock().unwrap().clone();
|
||||
assert_eq!(handed.len(), 1);
|
||||
assert_eq!(handed[0].len(), 1, "{handed:?}");
|
||||
assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_window_shows_the_active_photo() {
|
||||
let mut h = detail("panel.edit");
|
||||
|
||||
@@ -261,6 +261,29 @@ mod in_the_loupe {
|
||||
assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}");
|
||||
}
|
||||
|
||||
// Given soft proofing on at 1:1 (LR-VIEW-ZOOM), the view is a canvas-sized frame plus a
|
||||
// native window carrying the proof, as without it, instead of a whole-frame render at the
|
||||
// photo's full size; switching the proof off swaps the window back to the plain look
|
||||
#[test]
|
||||
fn soft_proofing_at_one_to_one_is_a_window() {
|
||||
let (mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
let plain = h.app.region_view.expect("a window render without the proof");
|
||||
let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": true, "destWarning": true}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.soft_proof);
|
||||
let main = rendered_long_edge(&h);
|
||||
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
|
||||
let proofed = h.app.region_view.expect("a window render with the proof");
|
||||
assert_eq!(proofed.full.0.max(proofed.full.1), native, "cut from the photo's own pixels");
|
||||
assert_ne!(proofed.settings, plain.settings, "the proof is part of the window's look");
|
||||
h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": false}}), T);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window");
|
||||
}
|
||||
|
||||
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
|
||||
#[test]
|
||||
fn an_explicit_limit_still_caps_one_to_one() {
|
||||
@@ -646,10 +669,16 @@ mod in_the_loupe {
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn profile_slider_drag() {
|
||||
for (size, percent) in
|
||||
[([1400.0, 900.0], 0.0), ([1400.0, 900.0], 100.0), ([1400.0, 900.0], 400.0), ([2800.0, 1700.0], 100.0), ([2800.0, 1700.0], 400.0)]
|
||||
{
|
||||
for (size, percent, proof) in [
|
||||
([1400.0, 900.0], 0.0, false),
|
||||
([1400.0, 900.0], 100.0, false),
|
||||
([1400.0, 900.0], 400.0, false),
|
||||
([2800.0, 1700.0], 100.0, false),
|
||||
([2800.0, 1700.0], 400.0, false),
|
||||
([1400.0, 900.0], 100.0, true),
|
||||
] {
|
||||
let (mut h, native) = detail_in(size);
|
||||
h.app.ui.soft_proof = proof;
|
||||
h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) };
|
||||
h.settle(SETTLE);
|
||||
h.app.session.begin_interaction("Exposure").unwrap();
|
||||
@@ -673,9 +702,10 @@ mod in_the_loupe {
|
||||
ms.sort_by(|a, b| a.total_cmp(b));
|
||||
let (w, hh) = region_tile(&h).unwrap_or((0, 0));
|
||||
eprintln!(
|
||||
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
|
||||
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit){} of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
|
||||
size[0],
|
||||
size[1],
|
||||
if proof { ", soft proof" } else { "" },
|
||||
ms[ms.len() / 2],
|
||||
ms[ms.len() * 9 / 10],
|
||||
ms[ms.len() - 1],
|
||||
@@ -819,18 +849,6 @@ mod in_the_loupe {
|
||||
assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
// Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame
|
||||
// render is as big as the view, not a canvas-sized upscale
|
||||
#[test]
|
||||
fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() {
|
||||
let (mut h, native) = detail();
|
||||
h.app.ui.soft_proof = true;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.region_view, None);
|
||||
assert_eq!(rendered_long_edge(&h), native);
|
||||
}
|
||||
|
||||
// Given a window the photo's spots make too big for one render (refused), the loupe falls back
|
||||
// to the whole frame at the drawn size, and tries windows again when the look changes
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Headless tests of resetting a slider (issue #1436): double-clicking its name or its thumb puts it
|
||||
//! back to its default in one undo step, as in Lightroom, and resting the pointer on either says so.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
/// The sliders the issue names, each set away from its default.
|
||||
const EDITED: [(&str, f64); 2] = [("light.exposure", 1.25), ("light.contrast", 40.0)];
|
||||
|
||||
/// The demo library in Detail with the Edit panel's Light section open.
|
||||
fn edit_panel() -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let id = h.app.session.visible_cloned()[0].0;
|
||||
assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true);
|
||||
h.app.ui.open_sections = vec!["light".to_string()];
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> egui::Rect {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
|
||||
}
|
||||
|
||||
fn value(h: &Headless, control: &str) -> f64 {
|
||||
let id = h.app.session.selection.active.unwrap();
|
||||
lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap()
|
||||
}
|
||||
|
||||
fn default(control: &str) -> f64 {
|
||||
lightcraft_develop::controls::find(control).unwrap().default
|
||||
}
|
||||
|
||||
fn ask(h: &mut Headless, method: &str, params: serde_json::Value) {
|
||||
let r = h.request(method, params.clone(), T);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
fn set(h: &mut Headless, control: &str, value: f64) {
|
||||
ask(h, "engine.execute", json!({"command": "develop.set", "params": {"control": control, "value": value}}));
|
||||
}
|
||||
|
||||
/// The thumb's place along the track (0..1), plus `dx` points: a hand lands near its centre, not on it.
|
||||
fn thumb_fx(h: &Headless, control: &str, dx: f32) -> f32 {
|
||||
let spec = lightcraft_develop::controls::find(control).unwrap();
|
||||
let track = widget(h, &format!("slider:{control}"));
|
||||
spec.position(value(h, control)) as f32 + dx / track.width()
|
||||
}
|
||||
|
||||
/// A double-click on a widget: the control channel plays each press and release in a frame of its
|
||||
/// own, so the slider sees the first click before the second, as with a hand on a mouse.
|
||||
fn double_click(h: &mut Headless, id: &str, fx: f32) {
|
||||
ask(h, "ui.clickWidget", json!({"id": id, "count": 2, "fx": fx}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_sliders_name_resets_it_in_one_step() {
|
||||
let mut h = edit_panel();
|
||||
for (control, edited) in EDITED {
|
||||
set(&mut h, control, edited);
|
||||
let undo = h.app.session.undo.len();
|
||||
double_click(&mut h, &format!("sliderLabel:{control}"), 0.1);
|
||||
assert_eq!(value(&h, control), default(control), "{control} is back at its default");
|
||||
assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step");
|
||||
ask(&mut h, "engine.execute", json!({"command": "edit.undo"}));
|
||||
assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back");
|
||||
}
|
||||
}
|
||||
|
||||
/// The first click of the double-click lands a few points off the thumb's centre: it leaves the value
|
||||
/// alone (it used to move it there, a history step of its own before the reset).
|
||||
#[test]
|
||||
fn double_clicking_a_sliders_thumb_resets_it_in_one_step() {
|
||||
let mut h = edit_panel();
|
||||
for (control, edited) in EDITED {
|
||||
set(&mut h, control, edited);
|
||||
let undo = h.app.session.undo.len();
|
||||
let fx = thumb_fx(&h, control, 4.0);
|
||||
double_click(&mut h, &format!("slider:{control}"), fx);
|
||||
assert_eq!(value(&h, control), default(control), "{control} is back at its default");
|
||||
assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step");
|
||||
ask(&mut h, "engine.execute", json!({"command": "edit.undo"}));
|
||||
assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back");
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the thumb leaves the value alone; a click elsewhere on the track still moves it there.
|
||||
#[test]
|
||||
fn a_click_on_the_thumb_keeps_the_value() {
|
||||
let mut h = edit_panel();
|
||||
set(&mut h, "light.exposure", 1.25);
|
||||
let fx = thumb_fx(&h, "light.exposure", -4.0);
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": fx}));
|
||||
assert_eq!(value(&h, "light.exposure"), 1.25);
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": 0.25}));
|
||||
assert!((value(&h, "light.exposure") - -2.5).abs() < 0.02, "{}", value(&h, "light.exposure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliders_name_and_thumb_say_a_double_click_resets_it() {
|
||||
let mut h = edit_panel();
|
||||
set(&mut h, "light.exposure", 1.25);
|
||||
let hint = "Exposure — double-click to reset it";
|
||||
let shown = |h: &Headless| h.painted_text().iter().any(|t| t == hint);
|
||||
let thumb = thumb_fx(&h, "light.exposure", 0.0);
|
||||
for (what, id, fx) in [("name", "sliderLabel:light.exposure", 0.1), ("thumb", "slider:light.exposure", thumb)] {
|
||||
// somewhere else first, so the hint of the step before goes
|
||||
ask(&mut h, "ui.hoverWidget", json!({"id": "sliderLabel:light.contrast", "fx": 0.9}));
|
||||
assert!(!shown(&h), "{what}: no hint left over");
|
||||
ask(&mut h, "ui.hoverWidget", json!({"id": id, "fx": fx}));
|
||||
// tooltips wait for the pointer to rest
|
||||
assert!(
|
||||
h.step_until(Duration::from_secs(5), shown),
|
||||
"{what}: {:?}",
|
||||
h.painted_text().iter().filter(|t| t.contains("reset")).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
// the rest of the track is for dragging and clicking: no hint there
|
||||
ask(&mut h, "ui.hoverWidget", json!({"id": "slider:light.exposure", "fx": 0.1}));
|
||||
for _ in 0..90 {
|
||||
h.step();
|
||||
}
|
||||
assert!(!shown(&h), "no hint away from the thumb");
|
||||
// the value keeps its own hint
|
||||
ask(&mut h, "ui.hoverWidget", json!({"id": "sliderValue:light.exposure"}));
|
||||
assert!(h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it")));
|
||||
}
|
||||
|
||||
/// Typing a value (KEY-SLIDER-TYPE, #1362) still works next to the reset.
|
||||
#[test]
|
||||
fn a_typed_value_still_applies_after_a_reset() {
|
||||
let mut h = edit_panel();
|
||||
set(&mut h, "light.exposure", 1.25);
|
||||
double_click(&mut h, "sliderLabel:light.exposure", 0.1);
|
||||
assert_eq!(value(&h, "light.exposure"), 0.0);
|
||||
let undo = h.app.session.undo.len();
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"}));
|
||||
for (method, params) in [("ui.text", json!({"text": "0.5"})), ("ui.key", json!({"key": "Enter"}))] {
|
||||
ask(&mut h, method, params);
|
||||
}
|
||||
assert_eq!(value(&h, "light.exposure"), 0.5);
|
||||
assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step");
|
||||
}
|
||||
@@ -218,10 +218,18 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String {
|
||||
if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }
|
||||
}
|
||||
|
||||
/// Pointer travel (points, sideways from the press) that turns a press on a slider's value into a
|
||||
/// scrub rather than a click that types a value.
|
||||
const SCRUB_START: f32 = 3.0;
|
||||
|
||||
/// The radius of a slider's hollow ring thumb.
|
||||
const THUMB_RING: f32 = 7.0;
|
||||
|
||||
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
|
||||
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Scroll sideways over
|
||||
/// it to nudge it. Click the value to type one: Return or clicking away applies it, Esc keeps the
|
||||
/// old one.
|
||||
/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment.
|
||||
/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it,
|
||||
/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth
|
||||
/// as far).
|
||||
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
@@ -257,19 +265,48 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
node.add_action(egui::accesskit::Action::Decrement);
|
||||
}
|
||||
});
|
||||
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
|
||||
// the name: double-click it to reset, as the thumb (a disabled slider's name is only text)
|
||||
let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() });
|
||||
// say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do
|
||||
let reset_hint = crate::i18n::tr_format!("{name} — double-click to reset it", name = label_text);
|
||||
let label_resp = if enabled { label_resp.on_hover_text(reset_hint.as_str()) } else { label_resp };
|
||||
crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone()));
|
||||
register(ui.ctx(), format!("sliderLabel:{}", spec.id), label_rect);
|
||||
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
|
||||
// the value: click it to type an exact one (over the label, so it takes the click)
|
||||
// the value: click it to type an exact one, drag it sideways to scrub (over the label, so it
|
||||
// takes the click)
|
||||
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() });
|
||||
crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value")));
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() });
|
||||
crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it")));
|
||||
register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect);
|
||||
// say that an exact value can be typed here (issue #534: an exact crop angle was hard to find)
|
||||
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp };
|
||||
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp };
|
||||
let typing_id = id.with("typing");
|
||||
// Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit.
|
||||
let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id));
|
||||
// A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead
|
||||
// of rounding back to the same step every frame.
|
||||
let scrub_id = id.with("scrub-pos");
|
||||
let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None };
|
||||
let mut scrub_started = false;
|
||||
if enabled
|
||||
&& scrub.is_none()
|
||||
&& value_resp.dragged()
|
||||
&& let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos()))
|
||||
&& (p.x - origin.x).abs() >= SCRUB_START
|
||||
{
|
||||
// a press on the value that moves sideways scrubs: the edit the press opened is dropped
|
||||
typing = None;
|
||||
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
|
||||
ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
|
||||
// the travel so far counts, so the value doesn't lag the pointer by the start distance
|
||||
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
|
||||
scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0));
|
||||
scrub_started = true;
|
||||
}
|
||||
if scrub.is_none() {
|
||||
ui.data_mut(|m| m.remove::<f64>(scrub_id));
|
||||
}
|
||||
let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
|
||||
// Focus on the owned primary press before fast typing; retain the readout's click activation.
|
||||
if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) {
|
||||
@@ -286,8 +323,35 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`)
|
||||
let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width();
|
||||
let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width()));
|
||||
// the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest
|
||||
// of the track, which is clicked and dragged, gets no tooltip)
|
||||
let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0;
|
||||
if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) {
|
||||
resp.clone().on_hover_text(reset_hint.as_str());
|
||||
}
|
||||
let mut v = value;
|
||||
if let Some((mut text, frames, pressed)) = typing.take() {
|
||||
if let Some(mut pos) = scrub {
|
||||
// scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel
|
||||
// over the track's width (a tenth with Shift), on the control's steps
|
||||
if !scrub_started && value_resp.dragged() {
|
||||
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
|
||||
let dx = ui.input(|i| i.pointer.delta().x);
|
||||
pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0);
|
||||
}
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max);
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
}
|
||||
out.drag_started = scrub_started;
|
||||
if value_resp.drag_stopped() {
|
||||
out.drag_stopped = true;
|
||||
ui.data_mut(|m| m.remove::<f64>(scrub_id));
|
||||
} else {
|
||||
ui.data_mut(|m| m.insert_temp(scrub_id, pos));
|
||||
}
|
||||
} else if let Some((mut text, frames, pressed)) = typing.take() {
|
||||
// Keep the owned opening release focused without restarting its draft or selection.
|
||||
// egui gives that release's click to the readout, so the field under it gives up the focus
|
||||
// whatever frame it lands on (issue #1362): the field takes it back after drawing too.
|
||||
@@ -358,6 +422,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
// a click on the thumb leaves it there, so the first click of a double-click on it isn't a
|
||||
// history step of its own before the reset
|
||||
&& !on_thumb(p)
|
||||
{
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max);
|
||||
@@ -454,11 +521,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c);
|
||||
if typing.is_none() {
|
||||
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);
|
||||
if enabled && value_resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
// the scrub pointer, as over Lightroom's values: a drag here changes the value
|
||||
if enabled && (value_resp.hovered() || scrub.is_some()) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
}
|
||||
let ring = 7.0;
|
||||
let ring = THUMB_RING;
|
||||
let tx = to_x(v);
|
||||
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
|
||||
let p = ui.painter();
|
||||
@@ -937,6 +1005,10 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S
|
||||
#[path = "widgets/tests_numeric_entry.rs"]
|
||||
mod numeric_entry_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "widgets/tests_slider_reset.rs"]
|
||||
mod slider_reset_tests;
|
||||
|
||||
fn focused_slider_key() -> egui::Id {
|
||||
egui::Id::new("lightcraft-focused-slider")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ struct Harness {
|
||||
enabled: bool,
|
||||
time: f64,
|
||||
rect: Rect,
|
||||
track: Rect,
|
||||
}
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
@@ -21,6 +22,7 @@ impl Harness {
|
||||
enabled: true,
|
||||
time: 0.0,
|
||||
rect: Rect::NOTHING,
|
||||
track: Rect::NOTHING,
|
||||
};
|
||||
for _ in 0..3 {
|
||||
h.frame(vec![]);
|
||||
@@ -33,7 +35,10 @@ impl Harness {
|
||||
self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| {
|
||||
out = slider(ui, &self.spec, self.value, self.enabled, None);
|
||||
});
|
||||
self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1;
|
||||
let registry = take_registry(&self.view.ctx);
|
||||
let find = |name: String| registry.iter().find(|(id, _)| id == &name).map(|(_, r)| *r);
|
||||
self.rect = find(format!("sliderValue:{}", self.spec.id)).unwrap();
|
||||
self.track = find(format!("slider:{}", self.spec.id)).unwrap();
|
||||
if let Some(v) = out.value {
|
||||
self.value = v;
|
||||
}
|
||||
@@ -64,9 +69,10 @@ fn opening_release_honors_return_and_escape_immediately() {
|
||||
|
||||
#[test]
|
||||
fn dragging_away_from_readout_does_not_capture_keyboard() {
|
||||
// straight down: no sideways travel, so the press neither types nor scrubs
|
||||
let mut h = Harness::new();
|
||||
let inside = h.rect.center();
|
||||
let outside = egui::pos2(5.0, 80.0);
|
||||
let outside = egui::pos2(inside.x, 80.0);
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] {
|
||||
let out = h.frame(events);
|
||||
@@ -94,3 +100,79 @@ fn typing_after_a_released_click_still_edits() {
|
||||
h.frame(vec![Event::Text("2".into())]);
|
||||
assert_eq!(h.key(Key::Enter).value, Some(2.0));
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// The track's width: a scrub moves the value as far as dragging the thumb would.
|
||||
fn track_width(&self) -> f32 {
|
||||
self.track.width()
|
||||
}
|
||||
/// Press on the value, move sideways by `dx` in `steps` frames (Shift held when `shift`) and release;
|
||||
/// the outputs of every frame.
|
||||
fn scrub(&mut self, dx: f32, steps: usize, shift: bool) -> Vec<SliderOut> {
|
||||
let modifiers = if shift { Modifiers::SHIFT } else { Modifiers::NONE };
|
||||
let start = self.rect.center();
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers };
|
||||
let mut outs = vec![self.frame(vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(start)]), self.frame(vec![button(start, true)])];
|
||||
for i in 1..=steps {
|
||||
let pos = start + vec2(dx * i as f32 / steps as f32, 0.0);
|
||||
outs.push(self.frame(vec![Event::PointerMoved(pos)]));
|
||||
}
|
||||
outs.push(self.frame(vec![button(start + vec2(dx, 0.0), false)]));
|
||||
outs.push(self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]));
|
||||
outs
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_value_sideways_scrubs_it_like_the_thumb() {
|
||||
let mut h = Harness::new();
|
||||
let width = f64::from(h.track_width());
|
||||
assert!(width > 100.0, "the slider has a track ({width})");
|
||||
// a fifth of the track: a fifth of the range (Exposure −5..+5 → +2 EV)
|
||||
let outs = h.scrub((width / 5.0) as f32, 10, false);
|
||||
assert!((h.value - 2.71).abs() < 0.015, "scrubbed to {}", h.value);
|
||||
assert_eq!(outs.iter().filter(|o| o.drag_started).count(), 1, "one interaction begins");
|
||||
assert_eq!(outs.iter().filter(|o| o.drag_stopped).count(), 1, "and ends, so the scrub is one undo step");
|
||||
assert!(outs.iter().filter_map(|o| o.value).count() > 1, "the value follows the pointer while it moves");
|
||||
assert!(!h.view.ctx.egui_wants_keyboard_input(), "a scrub opens no value field");
|
||||
assert!(h.key(Key::Enter).value.is_none(), "and leaves no draft to commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_back_and_with_shift() {
|
||||
let mut h = Harness::new();
|
||||
let width = h.track_width();
|
||||
// left lowers the value; on the control's steps and inside its range
|
||||
h.scrub(-width, 8, false);
|
||||
assert_eq!(h.value, -5.0, "clamped at the minimum");
|
||||
// Shift moves a tenth as far
|
||||
let mut h = Harness::new();
|
||||
h.scrub(width / 5.0, 10, true);
|
||||
assert!((h.value - 0.91).abs() < 0.015, "fine scrub to {}", h.value);
|
||||
assert!(((h.value / 0.01).round() * 0.01 - h.value).abs() < 1e-9, "on the 0.01 steps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_that_barely_moves_still_types() {
|
||||
// a hand's jitter under the scrub distance is a click: the value can be typed
|
||||
let mut h = Harness::new();
|
||||
let pos = h.rect.center();
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
h.frame(vec![Event::PointerMoved(pos)]);
|
||||
h.frame(vec![button(pos, true)]);
|
||||
h.frame(vec![Event::PointerMoved(pos + vec2(1.5, 0.5))]);
|
||||
assert_eq!(h.frame(vec![button(pos + vec2(1.5, 0.5), false)]).value, None);
|
||||
h.frame(vec![]);
|
||||
assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click");
|
||||
h.frame(vec![Event::Text("2".into())]);
|
||||
assert_eq!(h.key(Key::Enter).value, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_value_does_not_scrub() {
|
||||
let mut h = Harness::new();
|
||||
h.enabled = false;
|
||||
let width = h.track_width();
|
||||
assert!(h.scrub(width / 5.0, 10, false).iter().all(|o| o.value.is_none() && !o.drag_started));
|
||||
assert_eq!(h.value, 0.71);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Real egui pointer frames on a disabled slider: its name doesn't reset it (issue #1436).
|
||||
use super::{SliderOut, slider, take_registry};
|
||||
use crate::headless::HeadlessView;
|
||||
use egui::{Event, Modifiers, PointerButton, Rect, vec2};
|
||||
use lightcraft_develop::controls;
|
||||
|
||||
fn frame(view: &mut HeadlessView, time: f64, enabled: bool, events: Vec<Event>) -> (SliderOut, Rect) {
|
||||
let spec = controls::find("light.exposure").unwrap();
|
||||
let mut out = SliderOut::default();
|
||||
view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, time, events), |ui| {
|
||||
out = slider(ui, spec, 1.25, enabled, None);
|
||||
});
|
||||
let name = take_registry(&view.ctx).into_iter().find(|(id, _)| id == "sliderLabel:light.exposure").unwrap().1;
|
||||
(out, name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_sliders_name_ignores_a_double_click() {
|
||||
for enabled in [true, false] {
|
||||
let mut view = HeadlessView::new();
|
||||
let mut time = 0.0;
|
||||
let (_, name) = frame(&mut view, time, enabled, vec![]);
|
||||
let pos = name.left_center() + vec2(8.0, 0.0);
|
||||
let mut reset = false;
|
||||
for pressed in [None, Some(true), Some(false), Some(true), Some(false), None] {
|
||||
time += 0.02;
|
||||
let events = match pressed {
|
||||
None => vec![Event::PointerMoved(pos)],
|
||||
Some(pressed) => vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }],
|
||||
};
|
||||
let (out, _) = frame(&mut view, time, enabled, events);
|
||||
reset |= out.reset || out.value.is_some();
|
||||
}
|
||||
assert_eq!(reset, enabled, "enabled: {enabled}");
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,43 @@ SFace's builtin speed metadata now uses the measured 6.9× ratio. User `catalog.
|
||||
Older published tables for MobileFaceNet/R50/R100 came from a different runtime and are not measurements of this runner; architecture
|
||||
names alone do not guarantee the same speed, and those additional model files were not verified in this task.
|
||||
|
||||
### On the GPU
|
||||
|
||||
On a machine with a usable GPU, the whole detector and recogniser run on it through wgpu (`crates/gpu/src/faces.rs`).
|
||||
The interpreter lowers the graph to a flat program for the input's shape (`crates/faces/src/net/program.rs`): each
|
||||
convolution (`wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input straight into workgroup memory,
|
||||
cuts K into slices when a map is small, and a direct kernel for depthwise layers) and every other operator (activations,
|
||||
batch norm, PRelu, broadcasting add/sub/mul, max pooling, nearest upscaling, transposes, global pooling and the fully
|
||||
connected layer, `wgsl/faces_ops.wgsl`) is a kernel, every value stays on the device between layers in reused
|
||||
buffers, and a run is one upload, one submission and one read-back. Shape-only operators make no step. A model that
|
||||
needs something the program cannot express runs its convolutions one by one on the GPU instead. Several faces of one
|
||||
photo go together (`Embedder::embed_batch`, up to 16 per submission).
|
||||
|
||||
The CPU interpreter remains the reference. After a model loads, a background check (a row in the activity stack, "Checking
|
||||
the GPU for faces") runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when
|
||||
every output is within 1e-3 of the CPU's (relative to the output's largest value) **and** it was faster; until then,
|
||||
and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference, or **Settings ▸
|
||||
Faces ▸ Run face models on the CPU only**), missing, wrong or slower, the model runs on the CPU. The first GPU error,
|
||||
or a result of the wrong size or with values that are not finite, drops it for that model. Settings ▸ Faces shows
|
||||
which device each model runs on (`faces.gpu`). The weights stay on a device of the scan's own (separate from the
|
||||
editor's) while the model is loaded, at most 768 MB per model.
|
||||
|
||||
The background scan's pace also limits the GPU: paused or light, one model run at a time and the GPU busy at most a
|
||||
quarter of the time (the editor renders on the same GPU); normal, two at once and three quarters; full, as fast as it
|
||||
goes.
|
||||
|
||||
Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under very heavy load from
|
||||
other jobs (load average 100–250, so the absolute numbers are noisy):
|
||||
|
||||
| Model | CPU | GPU, one layer at a time | GPU, whole network | Batch of 16, per face |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| YuNet (640², one photo) | 58–60 ms | 85–89 ms | 21 ms | – |
|
||||
| SFace 2021dec | 40–42 ms | 47–51 ms | 9.6 ms | 8.1 ms |
|
||||
| AuraFace v1 | 295–308 ms | 165–172 ms | 70–73 ms | 71 ms |
|
||||
|
||||
`LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks
|
||||
agreement on the real models and every operator, and prints these timings.
|
||||
|
||||
### Agreement and regression coverage
|
||||
|
||||
The public-crop embeddings match the reference runtime's outputs:
|
||||
|
||||
+7
-4
@@ -16,9 +16,9 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| # | Gap | Evidence | User impact | Hours | Doc |
|
||||
|---:|---|---|---|---:|---|
|
||||
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
|
||||
| 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
|
||||
| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
|
||||
| 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) |
|
||||
| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) |
|
||||
| 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) |
|
||||
| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) |
|
||||
| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) |
|
||||
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
|
||||
@@ -36,14 +36,14 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
|
||||
| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
|
||||
| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) |
|
||||
| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) |
|
||||
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
|
||||
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
|
||||
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
|
||||
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
|
||||
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
|
||||
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
|
||||
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
|
||||
|
||||
Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them,
|
||||
**~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)).
|
||||
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
## Closed recently (kept for calibration)
|
||||
|
||||
- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like
|
||||
Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export.
|
||||
- 2026-10-11: Sony ILCA-99M2 profile.
|
||||
- 2026-10-11: Nikon edge bars on bodies without CropArea (#976, #914): 36 model / size / depth groups measured.
|
||||
- 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float
|
||||
@@ -80,6 +82,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
|
||||
| 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) |
|
||||
| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain |
|
||||
| 2026-10-14 | minor | Gap 4 closed: soft proofing at 1:1 renders a window (mask overlays already did); 1:1 pixels tested equal to export; only diagnostic overlays (Visualize Spots, Point Color range, HDR range) stay whole-frame |
|
||||
| 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice |
|
||||
| 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile |
|
||||
| 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) |
|
||||
|
||||
@@ -97,3 +97,12 @@ the path relative to the catalog that Lightroom records for root folders on the
|
||||
(`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying
|
||||
a project folder with its catalog inside it therefore keeps its originals linked. Other moved
|
||||
originals stay missing; use LightCraft's missing-photo relinking tools to update their paths.
|
||||
|
||||
Original and root folder paths are resolved with the catalog as the document and are looked at
|
||||
only when they are on this computer or on the network share the catalog itself is on
|
||||
(`crates/engine/src/doc_path.rs`). An original on another computer (`\\server\share\…`,
|
||||
`//server/…`, `file://server/…`), a device path, or one whose folder or file name in the catalog is
|
||||
an absolute path is not looked at: the import lists it as missing with a warning and leaves the
|
||||
photo and its virtual copies out of the library. To import originals on a share, open the catalog
|
||||
from that share. A path that is not a path on this computer (a Windows drive path read on macOS or
|
||||
Linux) is not looked at either and stays catalogued as missing.
|
||||
@@ -0,0 +1,77 @@
|
||||
# LightCraft in Georgian
|
||||
|
||||
Choose **Edit → Language → ქართული** or **Settings → General → Language**. The choice applies at once
|
||||
and is saved for the next launch (`language: "ka"` in `ui.json`); the control command is
|
||||
`app.language.georgian`. `LIGHTCRAFT_LANGUAGE` selects it for headless runs and accepts `ka`, `ka-GE`
|
||||
and `ka_GE.UTF-8`; the saved value is always `ka`. LightCraft does not follow the system language,
|
||||
for Georgian as for every other language.
|
||||
|
||||
The Georgian catalogs cover every key of the other catalogs plus all command and slider labels:
|
||||
menus, shortcuts, library, editing, crop, masks, people, import/export, settings, built-in presets and
|
||||
profiles, history, versions, date headings, status messages, the smart-album rule editor and the
|
||||
unsaved-changes dialog. The embedded release notes are translated too. Technical errors from lower
|
||||
layers stay in English, as in the other languages. They were written from the meaning of the English
|
||||
labels; no proprietary translation resources were consulted.
|
||||
|
||||
File names, command ids, template placeholders and albums, presets or metadata you named yourself are
|
||||
not translated. Descriptions for SAM 3 go to the model unchanged.
|
||||
|
||||
## Georgian script
|
||||
|
||||
Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the
|
||||
interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts
|
||||
are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with
|
||||
`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`.
|
||||
|
||||
Inter has no Georgian letters and craft-fonts has no Georgian face yet, so Georgian text shows boxes
|
||||
until a Georgian face is available to the UI.
|
||||
|
||||
## Terminology
|
||||
|
||||
| Source | Georgian |
|
||||
|---|---|
|
||||
| Library / Develop | ბიბლიოთეკა / დამუშავება |
|
||||
| File / Edit / View | ფაილი / რედაქტირება / ხედი |
|
||||
| Settings / General / Language | პარამეტრები / ზოგადი / ენა |
|
||||
| Import / Export | იმპორტი / ექსპორტი |
|
||||
| Photos / Albums / Folders | ფოტოები / ალბომები / საქაღალდეები |
|
||||
| Smart Album | სმარტ-ალბომი |
|
||||
| Crop | ჩამოჭრა |
|
||||
| Masks / Masking | ნიღბები / ნიღბვა |
|
||||
| Heal / Remove | აღდგენა / მოშორება |
|
||||
| Presets / Profile | პრესეტები / პროფილი |
|
||||
| History / Versions | ისტორია / ვერსიები |
|
||||
| Light / Color / Effects / Detail | სინათლე / ფერი / ეფექტები / დეტალი |
|
||||
| Exposure / Contrast | ექსპოზიცია / კონტრასტი |
|
||||
| Highlights / Shadows | ღია ტონები / ჩრდილები |
|
||||
| Whites / Blacks | თეთრები / შავები |
|
||||
| Texture / Clarity / Dehaze | ტექსტურა / სიცხადე / ბურუსის მოცილება |
|
||||
| Vibrance / Saturation | სიხალასე / გაჯერება |
|
||||
| Hue / Luminance | ფერის ტონი / სიკაშკაშე |
|
||||
| Tint | ტონირება |
|
||||
| Tone Curve / Color Grading | ტონალური მრუდი / ფერის გრადაცია |
|
||||
| Sharpening / Noise Reduction | გამკვეთრება / ხმაურის შემცირება |
|
||||
| Lens Corrections / Geometry | ობიექტივის კორექციები / გეომეტრია |
|
||||
| Vignette / Grain | ვინიეტი / მარცვლოვნება |
|
||||
| Feather / Threshold | კიდეების შერბილება / ზღვარი |
|
||||
| Rating / Flag / Keywords | შეფასება / დროშა / საკვანძო სიტყვები |
|
||||
| Reset / Revert | ჩამოყრა / წინა ვერსიაზე დაბრუნება |
|
||||
| Undo / Redo | დაბრუნება / გამეორება |
|
||||
|
||||
Please follow this table and the shared ArtCraft Georgian glossary when you add strings.
|
||||
|
||||
## Maintenance and checks
|
||||
|
||||
Fixed texts: `crates/ui-egui/locales/ka.json` (2,719 entries). Messages with values:
|
||||
`crates/ui-egui/locales/ka-formats.json` (249 entries). The English keys stay unchanged; Rust checks
|
||||
the placeholders at build time. Strings not yet in the plain catalog show in English.
|
||||
|
||||
```sh
|
||||
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
|
||||
LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000
|
||||
```
|
||||
|
||||
The tests check catalog coverage, placeholders, switching language by menu and control channel,
|
||||
saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case
|
||||
keeps Georgian lowercase. More notes are in
|
||||
[localization.md](localization.md).
|
||||
@@ -1,12 +1,12 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian interface catalog added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
How far each interface language is from `full`. How to add or maintain a language is in
|
||||
[localization.md](localization.md); per-language notes are in `localization-<code>.md`
|
||||
([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md),
|
||||
[pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md),
|
||||
[ru](localization-ru.md)).
|
||||
[ru](localization-ru.md), [ka](localization-ka.md)).
|
||||
|
||||
## The target
|
||||
|
||||
@@ -66,6 +66,7 @@ that is promising but is not recorded as review, so the column says `no (communi
|
||||
| Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h |
|
||||
| Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h |
|
||||
| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h |
|
||||
| Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) |
|
||||
|
||||
Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai
|
||||
(Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai
|
||||
@@ -94,5 +95,6 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English |
|
||||
| 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) |
|
||||
| 2026-10-12 | minor | Georgian (`ka`) row: every catalog message |
|
||||
| 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates |
|
||||
| 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) |
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
The interface ships in the language it is written in (English) plus every language in the table in
|
||||
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
|
||||
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`) and Czech (`cs`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`) and Georgian (`ka`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
|
||||
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
|
||||
[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md) and
|
||||
[`localization-cs.md`](localization-cs.md).
|
||||
[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md),
|
||||
[`localization-cs.md`](localization-cs.md) and [`localization-ka.md`](localization-ka.md).
|
||||
|
||||
## Adding a language
|
||||
|
||||
@@ -33,6 +33,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
|
||||
Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json");
|
||||
Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json");
|
||||
It, "it", "Italiano", "Latn", include_str!("../locales/it.json");
|
||||
Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json");
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,7 +47,9 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
|
||||
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
|
||||
language whose script has no face in the build shows boxes (the test says so instead of failing).
|
||||
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
|
||||
missing face is added there, not worked around in this repository.
|
||||
missing face is added there, not worked around in this repository. Sentence-cased messages go
|
||||
through `i18n::upper_first`, which leaves Georgian lowercase (upper-casing Mkhedruli would make
|
||||
Mtavruli).
|
||||
4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check).
|
||||
|
||||
## How a message is found
|
||||
|
||||
+19
-14
@@ -28,7 +28,7 @@ cited here still exists. Formerly docs/parity.md.
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) |
|
||||
| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) |
|
||||
| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| C. Views & navigation (VIEW) | 16 | 1 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
@@ -51,11 +51,11 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
|
||||
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) |
|
||||
| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) |
|
||||
| **Total** | 417 | 62 | 59 | 35 | 193/201 (96%) | 147/160 (92%) |
|
||||
| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 538 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 176.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps and shortcut notes
|
||||
@@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
|
||||
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered)Line truncated
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit.Line truncated
|
||||
@@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels |
|
||||
| LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) |
|
||||
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back |
|
||||
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
|
||||
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack |
|
||||
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
|
||||
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) |
|
||||
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules |
|
||||
@@ -130,8 +130,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) |
|
||||
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
|
||||
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takLine truncated
|
||||
| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
|
||||
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) |
|
||||
@@ -296,7 +296,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
|
||||
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic |
|
||||
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt |
|
||||
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
|
||||
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
|
||||
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider |
|
||||
@@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy |
|
||||
| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only |
|
||||
| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered |
|
||||
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet |
|
||||
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet |
|
||||
|
||||
## T. Share (SHARE)
|
||||
|
||||
@@ -480,8 +480,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu |
|
||||
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
@@ -672,9 +672,10 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
||||
| KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | `cmd:view.cropOverlayOrientation` | while cropping: O cycles the overlay, ⇧O its orientation |
|
||||
| KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | |
|
||||
| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active |
|
||||
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | |
|
||||
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/tests_slider_reset.rs` | double-click a slider's name or thumb: back to its default as one undo step (a click on the thumb no longer moves it first, which made it two); resting the pointer on either says so (“Exposure — double-click to reset it”, as the curve channels), the value keeps “Click to type a value” (issue #1436); a disabled slider's name doesn't reset it. No per-slider reset button (Lightroom has none); no ⌥ panel headings turning into Reset buttons yet (`cmd:develop.resetSection` is command-only) |
|
||||
| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each. Scrolling sideways over any slider (trackpad swipe, tilting wheel, ⇧+wheel) nudges it the same way, one wheel notch a step, without selecting it first; only a mostly sideways movement counts, so scrolling the panel leaves sliders alone (`crates/ui-egui/src/tests_masking.rs`, `sideways_scrolling_adjusts_the_slider_under_the_pointer`) |
|
||||
| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts |
|
||||
| KEY-SLIDER-SCRUB | Scrub a slider value — drag the number | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `SCRUB_START`), `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`dragging_the_value_sideways_scrubs_it_like_the_thumb`, `scrubbing_back_and_with_shift`, `a_press_that_barely_moves_still_types`) | drag the value next to any slider's name sideways: it moves as dragging the thumb would (the pointer's travel over the track's width, ⇧ a tenth as far), on the control's steps and range, as one undo step; a press that moves less than 3 points sideways stays a click that types a value (KEY-SLIDER-TYPE); the pointer over the value is the horizontal-resize cursor, as Lightroom's scrubby value |
|
||||
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
|
||||
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
|
||||
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
|
||||
@@ -776,7 +777,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes |
|
||||
| KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | |
|
||||
| KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor |
|
||||
| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys |
|
||||
| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys |
|
||||
| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), filter-bar keys |
|
||||
| KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection |
|
||||
| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste |
|
||||
@@ -810,6 +811,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-13 | minor | LR-BEHAV-WINDOW (new, 🟡): the desktop app's window opens at the size it was left at, zoomed / maximized if it was; full screen and the place on the desktop are not remembered |
|
||||
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the filler and noise columns on the right (a band), and the D600 and D610 measure their white level inside the image (#1627); status unchanged (🟡) |
|
||||
| 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged |
|
||||
| 2026-10-14 | patch | LR-MASK-BRUSH: ⇧-click with the brush draws a straight stroke from where the last stroke ended, tapering when the brush changed in between (`cmd:mask.brushStroke` `connect`); status unchanged |
|
||||
| 2026-10-14 | minor | LR-VIEW-ZOOM 🟡 → ✅: soft proofing gets zoom windows (mask overlays already had them; row text corrected), 1:1 pixels tested equal to the full-size export, slider-drag latency at 1:1 measured on Linux |
|
||||
| 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged |
|
||||
| 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) |
|
||||
| 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged |
|
||||
@@ -823,6 +826,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-13 | minor | KEYC-RATING: backtick cycles the flag (`photo.cycleFlag`), ⌘↑ / ⌘↓ raise / lower it (`photo.increaseFlag`, `photo.decreaseFlag`); status unchanged (🟡) |
|
||||
| 2026-10-13 | patch | LR-MASK-LUMRANGE / LR-MASK-COLORRANGE: range drags keep the grabbed endpoint; colour samples use the render's enabled sections and HDR/SDR settings; regression coverage, status unchanged |
|
||||
| 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) |
|
||||
| 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) |
|
||||
| 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) |
|
||||
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
|
||||
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
|
||||
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |
|
||||
|
||||
+22
-21
@@ -78,45 +78,45 @@ committed table is stale.
|
||||
|
||||
| Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported |
|
||||
|---|---:|---:|---:|---:|---:|---|
|
||||
| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
|
||||
| Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
|
||||
| Samsung | 128 | 4 | 115 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
|
||||
| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) |
|
||||
| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
|
||||
| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
|
||||
| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
|
||||
| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) |
|
||||
| Panasonic | 104 | 93 | 11 | 0 | 0 | — |
|
||||
| Nikon | 99 | 73 | 23 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only |
|
||||
| Nikon | 99 | 77 | 19 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only |
|
||||
| Fujifilm | 97 | 65 | 9 | 23 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) |
|
||||
| Google | 88 | 1 | 87 | 0 | 0 | — |
|
||||
| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** |
|
||||
| Google | 88 | 5 | 83 | 0 | 0 | — |
|
||||
| Olympus | 71 | 17 | 3 | 51 | 0 | **compressed ORF** |
|
||||
| Leica | 61 | 27 | 34 | 0 | 0 | — |
|
||||
| Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) |
|
||||
| Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — |
|
||||
| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF |
|
||||
| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF |
|
||||
| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) |
|
||||
| Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) |
|
||||
| DJI | 21 | 4 | 17 | 0 | 0 | DNG |
|
||||
| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** |
|
||||
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview |
|
||||
| **All** | **1,446** | **259 (18%)** | **919 (64%)** | **232 (16%)** | **36 (2%)** | |
|
||||
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) |
|
||||
| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | |
|
||||
|
||||
## By use (estimated)
|
||||
|
||||
| Bucket | Weight | Camera fully works | On a verified body | Why |
|
||||
|---|---:|---:|---:|---|
|
||||
| Canon | 33% | 94% | 72% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
|
||||
| Sony | 27% | 98% | 91% | bulk-checked |
|
||||
| Nikon | 16% | 98% | 83% | decoders cover every NEF coding but HE; 73 bodies verified |
|
||||
| Canon | 33% | 95% | 93% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
|
||||
| Sony | 27% | 98% | 92% | bulk-checked |
|
||||
| Nikon | 16% | 98% | 89% | decoders cover every NEF coding but HE; 77 bodies verified |
|
||||
| Fujifilm | 10% | 94% | 92% | |
|
||||
| Panasonic | 4% | 100% | 98% | bulk-checked |
|
||||
| OM / Olympus | 3% | 6% | 5% | compressed ORF |
|
||||
| Phones | 3% | 93% | 1% | DNG |
|
||||
| Pentax / Ricoh | 1.5% | 94% | 14% | |
|
||||
| Phones | 3% | 93% | 4% | DNG |
|
||||
| Pentax / Ricoh | 1.5% | 99% | 80% | |
|
||||
| Leica | 1% | 99% | 72% | the Q3 verified, 13 more bodies pinned on CC0 files |
|
||||
| DJI | 1% | 95% | 0% | |
|
||||
| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F |
|
||||
| **Weighted** | 100% | **92%** | **59%** | |
|
||||
| DJI | 1% | 96% | 19% | |
|
||||
| Medium format and others | 0.5% | 29% | 8% | IIQ, 3FR, X3F |
|
||||
| **Weighted** | 100% | **94%** | **85%** | |
|
||||
|
||||
About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually
|
||||
checked, and Canon, the largest bucket, is the weakest of the big four.
|
||||
About 19 in 20 raw photographers' cameras decode, and about 5 in 6 use a body we have actually
|
||||
checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus) is the largest gap left.
|
||||
|
||||
## Variant matrix (depth per format)
|
||||
|
||||
@@ -265,7 +265,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
|
||||
|---:|---|---:|---:|---:|---|
|
||||
| 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) |
|
||||
| 2 | CR3: remaining CRX variants (no public file uses them) | +0.3 | 10–25 | ~0.02 | files that open as a preview, from users |
|
||||
| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples |
|
||||
| 3 | ~~Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks)~~ done (#1045, #1081) | (+2.0, taken) | — | — | public samples |
|
||||
| 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists |
|
||||
| 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us |
|
||||
| 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples |
|
||||
@@ -310,3 +310,4 @@ Beyond decoding:
|
||||
| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) |
|
||||
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged |
|
||||
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
|
||||
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
|
||||
+1
-1
@@ -66,7 +66,7 @@ the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a
|
||||
|
||||
Local macOS, Linux and Windows packaging scripts automatically use the sibling `../craft-fonts`
|
||||
checkout. If it is absent, they clone it at revision
|
||||
`8dcdacd5153e64560d109541a47d806f26f048c0` (keep the pins in `packaging/fonts.sh` and
|
||||
`3d9ab3a132ed0eff457e424226a435a901da30d3` (keep the pins in `packaging/fonts.sh` and
|
||||
`packaging/windows/fonts.ps1` in sync with the release workflow). Existing checkouts are left untouched.
|
||||
Set `CRAFT_FONTS_DIR` to use another
|
||||
checkout; relative paths resolve from the workspace root. The script requires the Chinese and Japanese
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ calibration: M0–M13 took ≈ 25 active hours against an estimate of ≈ 110–
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; contact-sheet PDF 🟡; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian/Georgian localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Risks that coding hours alone don't retire
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point).
|
||||
|
||||
| Discount | Mainstream | Essentials | Evidence |
|
||||
|---|---:|---:|---|
|
||||
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) |
|
||||
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) |
|
||||
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) |
|
||||
| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) |
|
||||
| **Product** | **×0.804** | **×0.830** | |
|
||||
@@ -235,13 +235,13 @@ open as previews, and colour on every non-DNG raw is estimated rather than measu
|
||||
|
||||
**Alpha gate:** all six core workflows (import, cull, develop, save and reopen, batch looks, export)
|
||||
work end to end on macOS; see the table in [roadmap.md](roadmap.md#alpha-gate). The partial rows (CR3
|
||||
variants, compressed ORF, HE NEF as previews; capped 100% zoom) degrade but don't block a workflow.
|
||||
variants, compressed ORF, HE NEF as previews) degrade but don't block a workflow.
|
||||
|
||||
**To beta:** ~14 points of readiness, **~340–600 h**: the nine `B` gaps in [gaps.md](gaps.md)
|
||||
**To beta:** ~14 points of readiness, **~340–600 h**: the `B` gaps in [gaps.md](gaps.md)
|
||||
(~230–430 h), plus burning down the ~92 open core-path user issues (40–80 h) and stability work
|
||||
around them. Beta needs: CR3, ORF and
|
||||
HE NEF decoding from sensor data; measured colour for the popular models and a fidelity suite with
|
||||
scores; full-resolution zoom; Subject / Sky / People masks; a lens profile database for the
|
||||
scores; Subject / Sky / People masks; a lens profile database for the
|
||||
popular lenses; tests in CI on macOS, Windows and Linux.
|
||||
|
||||
## How hours are calibrated
|
||||
@@ -303,6 +303,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
|
||||
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
|
||||
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged |
|
||||
| 2026-10-14 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
|
||||
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
|
||||
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
|
||||
@@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
|
||||
| Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) |
|
||||
| Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) |
|
||||
| Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions |
|
||||
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | **original-pixel detail at 100% with a mask overlay or soft proofing** (no zoom window there; Detail, Before/After, Compare and Reference have them; P0 gap LR-VIEW-ZOOM) |
|
||||
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 |
|
||||
| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys |
|
||||
| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer |
|
||||
| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item |
|
||||
@@ -136,6 +136,7 @@ a real screen reader on any platform yet.
|
||||
| 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status |
|
||||
| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` grid thumbnail size keys noted in the shortcuts section |
|
||||
| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged |
|
||||
| 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed |
|
||||
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
|
||||
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
|
||||
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user