Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd811d679 | ||
|
|
6a852f8852 | ||
|
|
cb1c06fc57 | ||
|
|
f14817a5d2 | ||
|
|
61b6276c78 | ||
|
|
b4a949225d | ||
|
|
e8c7656150 | ||
|
|
82c58fabab | ||
|
|
5e0a6d404d | ||
|
|
962e0a2f98 | ||
|
|
973e7776ea | ||
|
|
2bf49f10a7 | ||
|
|
80d646fc17 | ||
|
|
ee05bc833b | ||
|
|
6ecad20132 | ||
|
|
b14037083d | ||
|
|
7e5d2ce6da | ||
|
|
bea81c2f3d | ||
|
|
3794ad471f | ||
|
|
d380309338 | ||
|
|
9237a8bdde | ||
|
|
39aa849b47 | ||
|
|
c20b81b251 | ||
|
|
4030096482 | ||
|
|
80b8deab0f | ||
|
|
17855ab3d1 | ||
|
|
afac33f8d7 | ||
|
|
5d8e5c138b | ||
|
|
9829d7ea37 | ||
|
|
f0322f74e8 | ||
|
|
51c671c5a0 | ||
|
|
8f102b0222 | ||
|
|
23417a60b6 | ||
|
|
fb36a3fdc4 | ||
|
|
60ef9d836f | ||
|
|
980cf05be4 | ||
|
|
538c5020b2 | ||
|
|
48c1721470 | ||
|
|
27222cf4b7 | ||
|
|
0e2c34af3e | ||
|
|
638b5cc4e6 | ||
|
|
7fd53f0e3f | ||
|
|
9a9d23d258 | ||
|
|
6f6d2ea467 | ||
|
|
6e9ba6511d | ||
|
|
ee9c9ef159 | ||
|
|
59820ecafb | ||
|
|
04e7e7d7f8 | ||
|
|
aba9b2b4ef | ||
|
|
1051265f75 | ||
|
|
b0e555c60f | ||
|
|
2af2cec6ea | ||
|
|
6de37ce7f0 | ||
|
|
6d08ae6400 | ||
|
|
c450e755ae | ||
|
|
872d3df2c5 | ||
|
|
5a907b110e | ||
|
|
599aea0086 | ||
|
|
f1059f1885 | ||
|
|
14733b5223 | ||
|
|
4ab61338ca | ||
|
|
886372812d | ||
|
|
42ee0d9675 | ||
|
|
c18ee8c88b | ||
|
|
92baf43103 | ||
|
|
aa1febaffb | ||
|
|
d706acdde9 | ||
|
|
661ba806d1 | ||
|
|
8584e5dbf8 | ||
|
|
4e334b479b | ||
|
|
f0d3156ad6 | ||
|
|
3455234537 | ||
|
|
da3f5ffd54 | ||
|
|
0142b24fcb | ||
|
|
2b2bb45870 | ||
|
|
4fe6dc6764 | ||
|
|
b26256faee | ||
|
|
d7e2f171f3 | ||
|
|
d523405627 | ||
|
|
cc772a03c1 | ||
|
|
8f7cf009bb | ||
|
|
5e5a0f6d2d | ||
|
|
7c2e8c3d95 | ||
|
|
17167e27d6 | ||
|
|
911f69d7b5 | ||
|
|
72bf3e13b6 | ||
|
|
680518b425 | ||
|
|
55c1847f89 | ||
|
|
6a4c48aea7 | ||
|
|
9e378277e8 | ||
|
|
18f787f04d | ||
|
|
e762c784fb | ||
|
|
d9ecd60fb3 | ||
|
|
40d7e6a1d8 | ||
|
|
f9e94aa535 | ||
|
|
2bbe6c3fdc | ||
|
|
cee1033112 | ||
|
|
cc75ed52c1 | ||
|
|
3674ed62bd | ||
|
|
06dcdc1267 | ||
|
|
a97cfd103e | ||
|
|
88ffd89e9f | ||
|
|
62e0335116 | ||
|
|
014dd5f25d | ||
|
|
f155374ac3 | ||
|
|
76c02862c6 | ||
|
|
b66ebb84d2 | ||
|
|
c234865b17 | ||
|
|
7aba2bd305 | ||
|
|
3e45d769fa | ||
|
|
a893113502 | ||
|
|
8458cf7095 | ||
|
|
f435eb0b55 | ||
|
|
b3993e89b3 | ||
|
|
5f55a29d2b | ||
|
|
56b192453e | ||
|
|
57cba5ef8e | ||
|
|
b1900ace28 | ||
|
|
bbb05cabfb | ||
|
|
539bf7ba14 | ||
|
|
d9445c7d1d | ||
|
|
4ecf8ea6b9 | ||
|
|
67747dfd02 | ||
|
|
bd3be48785 | ||
|
|
2d6ff3f6cc | ||
|
|
28874bbfad | ||
|
|
8a72978549 | ||
|
|
f92f13490b | ||
|
|
58406c38da | ||
|
|
b54fdfd94f | ||
|
|
139df7ae85 | ||
|
|
bb5d26e052 | ||
|
|
76c7d723f4 | ||
|
|
65e8d5f020 | ||
|
|
e5dbcab335 | ||
|
|
e26442657f | ||
|
|
740dd3019f | ||
|
|
0144cb283d | ||
|
|
a35af4e279 | ||
|
|
f8af27d261 | ||
|
|
308ef63294 | ||
|
|
9441484d16 | ||
|
|
b814fd5241 | ||
|
|
a7f26522ff | ||
|
|
2b19987bf1 | ||
|
|
da5abd10f9 | ||
|
|
3f0970923d | ||
|
|
28622aabfc | ||
|
|
66c0b59a23 | ||
|
|
c1bd296dcf | ||
|
|
3360f6e442 | ||
|
|
19f427aa00 | ||
|
|
d10ded55d6 | ||
|
|
5025fd2895 | ||
|
|
d22a9fac6e | ||
|
|
7fd5715dbd | ||
|
|
fd351918e5 | ||
|
|
6a2aaebe99 | ||
|
|
d8d8d9f3d2 | ||
|
|
c0110fb4d2 | ||
|
|
deb419eeb2 | ||
|
|
a1de9ce4c8 | ||
|
|
acce8e0e99 | ||
|
|
0aaf5b60fe | ||
|
|
d67e28c118 | ||
|
|
2c3d972f18 | ||
|
|
53d09f5850 | ||
|
|
989644cabd | ||
|
|
0f8b370621 | ||
|
|
0fb4bbf1db | ||
|
|
1fdb0378a3 | ||
|
|
16bdcbd54e | ||
|
|
1f742629bb | ||
|
|
d45b3718ce | ||
|
|
7b88189e2d | ||
|
|
ade5799165 | ||
|
|
1a54384d80 | ||
|
|
2d2e4993a1 | ||
|
|
75101ddce2 | ||
|
|
52e130d481 | ||
|
|
30bd8a5598 | ||
|
|
3cbd727b85 | ||
|
|
585131440a | ||
|
|
7a6058e6b4 | ||
|
|
885b754414 | ||
|
|
1234ba4c6f | ||
|
|
7c96e9e120 | ||
|
|
6601bbc3e7 | ||
|
|
60339cf675 | ||
|
|
10fb9233d5 | ||
|
|
cb9513c718 | ||
|
|
026432815c | ||
|
|
55a617cd66 | ||
|
|
a0ff3a01f2 | ||
|
|
f781cb70fc | ||
|
|
509b596805 | ||
|
|
3e3e580217 | ||
|
|
ecbd3d78be | ||
|
|
0b4b1e4286 | ||
|
|
7107c80042 | ||
|
|
ec28fdd03d | ||
|
|
42e4af6416 | ||
|
|
614d06892c | ||
|
|
984bc5c715 | ||
|
|
a874b149f2 | ||
|
|
e7c5df0d69 | ||
|
|
167ea87ef1 | ||
|
|
3e90c81e3c | ||
|
|
51f0a287b1 | ||
|
|
9b3c6359ae | ||
|
|
c732ecd5b0 | ||
|
|
a030d19437 | ||
|
|
79e8ad35a6 | ||
|
|
1d425ce8f9 | ||
|
|
017d1aea1c | ||
|
|
2e0c426692 | ||
|
|
1f64daeba4 | ||
|
|
5866cd2cf6 | ||
|
|
c436aa7294 | ||
|
|
3af4c46a18 | ||
|
|
bacc825d33 | ||
|
|
20dab123fb | ||
|
|
7609834df8 | ||
|
|
d79fc5e8ef | ||
|
|
830541ded2 | ||
|
|
203b5796b4 | ||
|
|
ac19db1046 | ||
|
|
273ff014e3 | ||
|
|
1608b7b309 | ||
|
|
b7b3117aea | ||
|
|
009bef76e7 | ||
|
|
80519e1a17 | ||
|
|
aa509ca966 | ||
|
|
2189a1610e | ||
|
|
d96bb50161 | ||
|
|
303b7849e8 | ||
|
|
1d67cae1ba | ||
|
|
59da288daa | ||
|
|
11817fcb12 | ||
|
|
468b8f2792 | ||
|
|
8a9f4722d2 | ||
|
|
073e1cc1d2 | ||
|
|
d8217b3476 | ||
|
|
094d0776d1 | ||
|
|
09d079278e | ||
|
|
67e3d9be9f | ||
|
|
6cfed3a7dc | ||
|
|
e46f119e03 | ||
|
|
01833cfac2 | ||
|
|
21326ded43 | ||
|
|
93f2ff835a | ||
|
|
a9aabcdfde | ||
|
|
233237e9dc | ||
|
|
f6dd0aa6f3 | ||
|
|
458870dadc | ||
|
|
5c9d9116bc | ||
|
|
f11380d5bd | ||
|
|
ec60f43b5b | ||
|
|
37929405df | ||
|
|
fa2fe1c90f | ||
|
|
64ec252dbe | ||
|
|
cf83e05821 | ||
|
|
cfd12263af | ||
|
|
3188866b25 | ||
|
|
2785699464 | ||
|
|
de58a0a778 | ||
|
|
334ae5779c | ||
|
|
ee3839955b | ||
|
|
c54b3c4a02 | ||
|
|
e44d96e1d4 | ||
|
|
f9c5486ffe | ||
|
|
c25630b5fa | ||
|
|
d5bab13674 | ||
|
|
03d45a80ee | ||
|
|
c6a525cc99 | ||
|
|
ea4579ea11 | ||
|
|
1ccdac4c94 |
No files matched your search
@@ -19,47 +19,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
# 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
|
||||
|
||||
verified-appimage-inputs:
|
||||
name: Verified AppImage inputs
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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 packaging/linux/AppRun
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.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)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/photocraft.wxs packaging/windows/photocraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", 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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: bash packaging/linux/verified-download-test.sh
|
||||
@@ -1,9 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "PhotoCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -17,477 +11,27 @@ 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
|
||||
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (today the Japanese fonts). An optional build input for everyone else; see
|
||||
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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 "### PhotoCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
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:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_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:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
|
||||
- name: Verify signatures and notarization
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/verify.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
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:
|
||||
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:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
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 }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
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 }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
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:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
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@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_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/photocraft$|metainfo|applications'
|
||||
ldd target/release/photocraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_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/photocraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_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/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
|
||||
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
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. Same recipe as GridCraft's linux-riscv64 job.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_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 \
|
||||
desktop-file-utils appstream
|
||||
- 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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
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@v4
|
||||
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="PhotoCraft 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: photocraft
|
||||
app-title: PhotoCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
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 }}
|
||||
@@ -22,72 +22,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@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: photocraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: photocraft-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 'PhotoCraft'
|
||||
foreach ($exe in 'photocraft.exe', 'photocraft-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 'photocraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok photocraft-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 'photocraft.exe')) { throw 'uninstall left photocraft.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: photocraft
|
||||
app-dir: PhotoCraft
|
||||
@@ -18,17 +18,27 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/pen-add-anchor.svg`, `assets/icons/pen-delete-anchor.svg` | Add Anchor Point and Delete Anchor Point tool glyphs: Lucide `pen-tool` plus a + or − mark | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) |
|
||||
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
|
||||
|
||||
@@ -4199,6 +4199,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
@@ -4372,6 +4373,7 @@ dependencies = [
|
||||
"roxmltree",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
]
|
||||
@@ -4597,6 +4599,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -81,8 +81,8 @@ codegen-units = 1
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI shell, and "s" for
|
||||
# other crates except pixel processing, which keeps opt-level 3 for filters and compositing. panic
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
|
||||
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
|
||||
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
|
||||
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
|
||||
[profile.wasm-release]
|
||||
@@ -100,6 +100,15 @@ opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-ui-egui]
|
||||
opt-level = "z"
|
||||
|
||||
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
|
||||
# builds and the speed-oriented pixel-processing overrides below retain their settings.
|
||||
[profile.wasm-release.package.photocraft-engine]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-io]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-web]
|
||||
opt-level = "z"
|
||||
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
|
||||
@@ -133,7 +133,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<br>
|
||||
<sub>A badge made of shape layers: a gradient-filled lotus, a star and a dotted ring.<br><i>Water Lilies</i>, Claude Monet, 1906</sub>
|
||||
<h3>Pixel-perfect vectors</h3>
|
||||
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen tool, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
|
||||
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen, Freeform Pen and Curvature Pen tools, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
|
||||
<br><br>
|
||||
Combine shapes (unite, subtract, intersect, exclude), keep paths in the Paths panel, use them as vector masks, or stroke and fill them. 116 of 116 shape layers in our PSD corpus match Photoshop's pixels.
|
||||
</td>
|
||||
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 55 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
<h4>🧰 67 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR and Radiance HDR, and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: full re-measure against Photoshop 2026 27.11.0) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
@@ -19,7 +19,7 @@ About 80% of the work parallelizes.
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 53/68, panels 30/35, formats 13+4 of 31 |
|
||||
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 56/68, panels 30/35, formats 13+4 of 31 |
|
||||
| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below |
|
||||
| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) |
|
||||
| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) |
|
||||
@@ -95,7 +95,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
|
||||
| Simplified Chinese | `zh-hans` | 100% | partial |
|
||||
| Spanish | `es` | 100% | partial |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 0% (no RTL UI) | none |
|
||||
| Arabic | `ar` | 0% (RTL shell only) | none |
|
||||
| French | `fr` | 100% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% | partial |
|
||||
| Indonesian | `id` | 100% | partial |
|
||||
@@ -114,7 +114,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
|
||||
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
|
||||
| 3 | User bug backlog to under 100 open | 250–400 h |
|
||||
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
|
||||
| 5 | The 15 missing tools | 60–100 h |
|
||||
| 5 | The 12 missing tools | 60–100 h |
|
||||
|
||||
Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
@@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -14,20 +15,25 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
|
||||
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
|
||||
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Apply an action list to every image in a directory. Steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An --out folder that is the
|
||||
--in folder is refused, as the results would replace the originals; --in-place allows it.
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
|
||||
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
|
||||
when the set holds more than one) with the whole set loaded, so its steps can play other actions
|
||||
of the set. An --out folder that is the --in folder is refused, as the results would replace
|
||||
the originals; --in-place allows it.
|
||||
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
|
||||
Run a droplet (File › Automate › Create Droplet) on images and folders.
|
||||
photocraft-cli commands [--json] [--filter <text>]
|
||||
@@ -39,7 +45,7 @@ USAGE:
|
||||
[--automation-read-root <dir>] [--automation-write-root <dir>]
|
||||
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
|
||||
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
|
||||
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
|
||||
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
|
||||
(docs/control-protocol.md#headless-server).
|
||||
|
||||
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
|
||||
@@ -61,10 +67,20 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand { name: "run", values: &["--new", "--cmd", "--params", "--out", "--format", "--quality"], bare: &["--tiff-layers"], run: run_cmds },
|
||||
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place", "--tiff-layers"], run: batch },
|
||||
Subcommand {
|
||||
name: "run",
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
|
||||
bare: &["--tiff-layers"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
|
||||
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
|
||||
Subcommand {
|
||||
@@ -194,6 +210,9 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
o.encode.webp_quality = q;
|
||||
o.encode.webp_lossless = false;
|
||||
}
|
||||
if let Some(b) = a.get("--tga-bits") {
|
||||
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -330,7 +349,39 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
|
||||
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
|
||||
steps_of(&v)
|
||||
}
|
||||
|
||||
fn steps_of(v: &Value) -> Result<Steps, String> {
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Engine commands to run in order: `(id, params)`.
|
||||
pub type Steps = Vec<(String, Value)>;
|
||||
|
||||
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
|
||||
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
|
||||
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
|
||||
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
|
||||
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
let list = v.get("actions").unwrap_or(&v);
|
||||
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
|
||||
if !is_set {
|
||||
if name.is_some() {
|
||||
return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into());
|
||||
}
|
||||
return Ok((steps_of(&v)?, Vec::new()));
|
||||
}
|
||||
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?;
|
||||
let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
|
||||
let action = match (name, set.as_slice()) {
|
||||
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?,
|
||||
(None, [only]) => only,
|
||||
(None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action <name> ({})", set.len(), names())),
|
||||
};
|
||||
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
|
||||
Ok((play, set))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
|
||||
@@ -352,7 +403,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
|
||||
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
|
||||
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
|
||||
let actions = parse_actions(&text)?;
|
||||
let (actions, set) = load_actions(&text, a.get("--action"))?;
|
||||
let opts = export_opts(a)?;
|
||||
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
|
||||
// replace the originals (#492).
|
||||
@@ -378,9 +429,19 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let r = (|| -> Result<Vec<String>, String> {
|
||||
written.check(&target_text)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
// Steps that play another action find it in the set; the session's playback stack
|
||||
// stops an action that calls itself.
|
||||
h.session.actions.list = set.clone();
|
||||
h.open(input).map_err(|e| e.to_string())?;
|
||||
for (id, p) in &actions {
|
||||
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
|
||||
continue;
|
||||
}
|
||||
let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
// A failed step inside a called action makes this file an error, not a saved result.
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
|
||||
return Err(format!("`{id}`: {e}"));
|
||||
}
|
||||
}
|
||||
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
|
||||
@@ -589,3 +650,54 @@ mod missing_font_warning_tests {
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod batch_action_set_tests {
|
||||
use super::*;
|
||||
|
||||
fn invoke(args: &[&str]) -> (i32, String, String) {
|
||||
let (mut output, mut errors) = (Vec::new(), Vec::new());
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let code = run(&args, &mut output, &mut errors);
|
||||
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
|
||||
}
|
||||
|
||||
/// A batched action that plays another action of its set finds it in every file's session; a
|
||||
/// failure inside the called action fails the file, and a self-call stops instead of looping.
|
||||
#[test]
|
||||
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
std::fs::create_dir_all(dir.join("in")).unwrap();
|
||||
for name in ["a", "b"] {
|
||||
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
}
|
||||
let set = json!({"version": 1, "actions": [
|
||||
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
|
||||
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
|
||||
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
|
||||
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
|
||||
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
|
||||
]});
|
||||
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
|
||||
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
|
||||
|
||||
let (code, stdout, stderr) = batch("Outer");
|
||||
assert_eq!(code, 0, "{stdout}{stderr}");
|
||||
for name in ["a", "b"] {
|
||||
let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
|
||||
}
|
||||
for action in ["Calls bad", "Loop"] {
|
||||
let (code, stdout, stderr) = batch(action);
|
||||
assert_ne!(code, 0, "{stdout}");
|
||||
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
|
||||
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -189,9 +189,11 @@ fn run_commands_and_save() {
|
||||
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[2]["command"], "document.inspect");
|
||||
assert_eq!(lines[2]["result"]["name"], "Run");
|
||||
let (info, _) = ok(bin().arg("info").arg(&out_pc));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!(v["name"], "Run");
|
||||
// Saving a native document gives the reopened bundle its output filename.
|
||||
assert_eq!(v["name"], "r.pcraft");
|
||||
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Ink", "Background"]);
|
||||
// Continue from the saved file and export PNG.
|
||||
@@ -264,6 +266,37 @@ fn batch_applies_actions_to_directory() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_skips_recorded_view_and_save_steps() {
|
||||
let d = tmp("batch-shell-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let source = input.join("a.png");
|
||||
write_png(&source, 12, 8, 3);
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let recorded_save = d.join("recorded-save.psd");
|
||||
let mut cases = Vec::new();
|
||||
for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] {
|
||||
cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
}
|
||||
cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
|
||||
for (name, actions) in cases {
|
||||
let o = batch_with(&d, &input, name, &actions, &[]);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
|
||||
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
|
||||
let output = d.join(name).join("a.png");
|
||||
let (info, _) = ok(bin().arg("info").arg(&output));
|
||||
let info: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
|
||||
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
|
||||
assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_actions_formats() {
|
||||
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
|
||||
@@ -378,6 +411,32 @@ fn quality_outside_1_to_100_is_refused() {
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||||
/// refused and nothing is written.
|
||||
#[test]
|
||||
fn tga_bits_choose_the_targa_depth() {
|
||||
let d = tmp("tga-bits");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
|
||||
let out = d.join("a.tga");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--tga-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
|
||||
}
|
||||
for bad in ["16", "0", "abc", ""] {
|
||||
let out = d.join("bad.tga");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
|
||||
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
|
||||
@@ -125,14 +125,19 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&a));
|
||||
ok(bin().arg("convert").arg(&a).arg(&b));
|
||||
// Everything but the file's own path must match.
|
||||
// Everything but the file's own path and saved document name must match.
|
||||
let info = |p: &Path| {
|
||||
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
|
||||
v.as_object_mut().unwrap().remove("file");
|
||||
v.as_object_mut().unwrap().remove("path");
|
||||
v
|
||||
};
|
||||
let (ia, ib) = (info(&a), info(&b));
|
||||
let (mut ia, mut ib) = (info(&a), info(&b));
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia["name"], "a.pcraft");
|
||||
assert_eq!(ib["name"], "b.pcraft");
|
||||
ia.as_object_mut().unwrap().remove("name");
|
||||
ib.as_object_mut().unwrap().remove("name");
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
|
||||
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco",
|
||||
"ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -350,6 +350,8 @@ fn services(inbox: Inbox) -> Services {
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts.image_previews = settings.image_previews;
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| match request {
|
||||
|
||||
@@ -64,6 +64,7 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Why a File › Open / Save dialog may never appear on Linux (#3354).
|
||||
//!
|
||||
//! `rfd` talks to the XDG file-chooser portal through `libdbus-1`, which it loads at runtime, and
|
||||
//! falls back to `zenity`. When neither works (a minimal window-manager session without a portal
|
||||
//! backend) it logs the failure and answers "nothing picked", which the app took for Cancel: the
|
||||
//! menu item did nothing and said nothing. [`start`] looks for the pieces once, in the background,
|
||||
//! and [`known_problem`] returns what to install when a dialog came back empty and none of them
|
||||
//! is there.
|
||||
//!
|
||||
//! The probe only ever explains an empty answer; it never stops a dialog from opening. Anything
|
||||
//! it can't tell counts as present, so a working system never sees the message.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// What the probe found. `None` means it couldn't tell, which counts as present.
|
||||
// Only Linux probes; the types and helpers stay testable on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Findings {
|
||||
/// `libdbus-1.so.3` is in the linker cache (`rfd` loads it for the portal).
|
||||
pub libdbus: Option<bool>,
|
||||
/// The session bus has the portal's `FileChooser` interface (a portal and a backend for it).
|
||||
pub file_chooser_portal: Option<bool>,
|
||||
/// `zenity` is on `PATH` (`rfd`'s fallback).
|
||||
pub zenity: bool,
|
||||
}
|
||||
|
||||
/// The message for `findings`, or `None` when some file dialog should work.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn problem(findings: Findings) -> Option<String> {
|
||||
let portal = findings.libdbus != Some(false) && findings.file_chooser_portal != Some(false);
|
||||
if portal || findings.zenity {
|
||||
return None;
|
||||
}
|
||||
let cause = if findings.libdbus == Some(false) { "libdbus is missing" } else { "no desktop portal offers a file chooser" };
|
||||
Some(format!(
|
||||
"No file dialog can open: {cause}. Install xdg-desktop-portal with a backend (xdg-desktop-portal-gtk, -kde or -gnome) and libdbus, or zenity. Meanwhile, drop a file onto the window or paste a copied image."
|
||||
))
|
||||
}
|
||||
|
||||
static RESULT: OnceLock<Option<String>> = OnceLock::new();
|
||||
|
||||
/// The problem found by [`start`], once it has finished.
|
||||
pub fn known_problem() -> Option<String> {
|
||||
RESULT.get().cloned().flatten()
|
||||
}
|
||||
|
||||
/// Whether `program` is an executable file in one of the `path` directories.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn on_path(program: &str, path: &std::ffi::OsStr) -> bool {
|
||||
std::env::split_paths(path).any(|dir| dir.join(program).is_file())
|
||||
}
|
||||
|
||||
/// Look for the file dialog backends on a helper thread (the portal call can take a moment).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start() {
|
||||
let spawned = std::thread::Builder::new().name("file dialog probe".into()).spawn(|| {
|
||||
let findings = Findings {
|
||||
libdbus: crate::linux_libs::cached("libdbus-1.so.3"),
|
||||
file_chooser_portal: file_chooser_portal(),
|
||||
zenity: std::env::var_os("PATH").is_some_and(|path| on_path("zenity", &path)),
|
||||
};
|
||||
log::info!("file dialog backends: {findings:?}");
|
||||
let _ = RESULT.set(problem(findings));
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("couldn't start the file dialog probe: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
|
||||
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn file_chooser_portal() -> Option<bool> {
|
||||
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
|
||||
return Some(false);
|
||||
};
|
||||
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
|
||||
.destination("org.freedesktop.portal.Desktop")
|
||||
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
|
||||
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
|
||||
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
|
||||
.ok()?;
|
||||
match proxy.get_property::<u32>("version") {
|
||||
Ok(_) => Some(true),
|
||||
Err(zbus::Error::MethodError(name, _, _)) => {
|
||||
let name = name.as_str();
|
||||
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
|
||||
absent.then_some(false)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn found(libdbus: Option<bool>, file_chooser_portal: Option<bool>, zenity: bool) -> Findings {
|
||||
Findings { libdbus, file_chooser_portal, zenity }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_backend_is_not_reported() {
|
||||
assert_eq!(problem(found(Some(true), Some(true), false)), None, "portal");
|
||||
assert_eq!(problem(found(Some(false), Some(false), true)), None, "zenity alone");
|
||||
assert_eq!(problem(found(None, None, false)), None, "unknown counts as present");
|
||||
assert_eq!(problem(found(Some(true), None, false)), None, "slow portal reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_show_a_dialog_with_is_explained() {
|
||||
let no_portal = problem(found(Some(true), Some(false), false)).unwrap();
|
||||
assert!(no_portal.contains("no desktop portal offers a file chooser") && no_portal.contains("xdg-desktop-portal"), "{no_portal}");
|
||||
let no_dbus = problem(found(Some(false), Some(true), false)).unwrap();
|
||||
assert!(no_dbus.contains("libdbus is missing"), "{no_dbus}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_are_found_on_the_given_path() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-probe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("zenity"), b"").unwrap();
|
||||
let path = std::env::join_paths([std::path::Path::new("/nonexistent"), dir.as_path()]).unwrap();
|
||||
assert!(on_path("zenity", &path));
|
||||
assert!(!on_path("kdialog", &path));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -267,6 +267,14 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
/// crashes on them on old Intel Macs (Iris Pro, GPUFamily 1 on Monterey), so no mode could start
|
||||
/// (#3107). Only Intel Macs skip them: PhotoCraft issues no indirect draws or dispatches, but every
|
||||
/// other GPU keeps wgpu's default. `WGPU_VALIDATION_INDIRECT_CALL=1` still wins.
|
||||
fn skips_indirect_validation(os: Os, intel: bool) -> bool {
|
||||
os == Os::Mac && intel
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
@@ -275,6 +283,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
if skips_indirect_validation(os, cfg!(target_arch = "x86_64")) {
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
if os == Os::Windows {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
@@ -797,4 +808,28 @@ mod tests {
|
||||
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// Metal's compiler crashed on them on Intel Iris Pro, losing the device in every mode (#3107).
|
||||
assert!(skips_indirect_validation(Os::Mac, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (os, intel) in [(Os::Mac, false), (Os::Windows, true), (Os::Other, true), (Os::Other, false)] {
|
||||
assert!(!skips_indirect_validation(os, intel), "{os:?} intel={intel}");
|
||||
}
|
||||
for (os, safe_gpu) in [(Os::Windows, false), (Os::Other, true), (Os::Other, false)] {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
|
||||
}
|
||||
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,9 +111,10 @@ pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
/// A real `ldconfig -p` listing starts with "N libs found in cache".
|
||||
/// A real `ldconfig -p` listing starts with "N libs found in cache" (translated on a system whose
|
||||
/// locale `LC_ALL=C` doesn't reach): a count, then the cache's name.
|
||||
fn ldconfig_output_is_valid(output: &str) -> bool {
|
||||
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
|
||||
output.lines().next().is_some_and(|l| l.split_whitespace().next().is_some_and(|n| n.parse::<u32>().is_ok()))
|
||||
}
|
||||
|
||||
/// Where we look for libraries, and what we found there.
|
||||
@@ -245,7 +246,9 @@ fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
|
||||
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
|
||||
// The listing is parsed, so it must be the untranslated one: a German system prints
|
||||
// "5522 Bibliotheken im Cache", which `ldconfig_output_is_valid` took for no cache at all.
|
||||
if let Ok(out) = std::process::Command::new(cmd).arg("-p").env("LC_ALL", "C").output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
@@ -262,6 +265,12 @@ fn system_probe() -> Probe {
|
||||
Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) }
|
||||
}
|
||||
|
||||
/// Whether the linker cache lists `soname`; `None` when the cache can't be read, which proves
|
||||
/// nothing (musl, NixOS, some containers).
|
||||
pub fn cached(soname: &str) -> Option<bool> {
|
||||
read_ldconfig().map(|text| ldconfig_has(&text, soname, ldconfig_arch_tag()))
|
||||
}
|
||||
|
||||
/// Are all the libraries `session` needs found on this system? (For choosing a display server:
|
||||
/// libraries the check can't find count as missing.)
|
||||
pub fn available(session: DisplaySession) -> bool {
|
||||
@@ -325,6 +334,10 @@ mod tests {
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
assert!(ldconfig_output_is_valid(CACHE));
|
||||
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
|
||||
// A translated listing is still a listing (it used to be rejected, so the check was
|
||||
// silently skipped on non-English systems).
|
||||
assert!(ldconfig_output_is_valid("5522 Bibliotheken im Cache »/etc/ld.so.cache« gefunden\n\tlibz.so.1 (libc6,x86-64) => /usr/lib/libz.so.1\n"));
|
||||
assert!(!ldconfig_output_is_valid("Error: kein Cache\n"));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
|
||||
//! gets `id#2` for its second copy, as muda ids must be unique.
|
||||
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
|
||||
//! else (labels, enabled, checked) is updated in place.
|
||||
//! else (labels, enabled, checked) is updated in place. Opening or closing a document rebuilds
|
||||
//! only the Window menu, whose list of documents changed: AppKit adds its own items to that
|
||||
//! menu (window tiling, the window list), so muda can't insert rows there by position.
|
||||
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
|
||||
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
|
||||
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
|
||||
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
@@ -49,6 +51,8 @@ pub struct MacMenu {
|
||||
entries: HashMap<String, Entry>,
|
||||
rx: Receiver<Chosen>,
|
||||
structure: u64,
|
||||
/// The Window menu, the muda ids of its items, and its [`native_menu::window_key`].
|
||||
window: Option<(Submenu, Vec<String>, u64)>,
|
||||
}
|
||||
|
||||
impl MacMenu {
|
||||
@@ -67,7 +71,7 @@ impl MacMenu {
|
||||
// Wake egui, so the item runs now rather than on the next mouse move.
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 }
|
||||
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0, window: None }
|
||||
}
|
||||
|
||||
fn build(&mut self, bar: &MenuBar) {
|
||||
@@ -75,12 +79,15 @@ impl MacMenu {
|
||||
old.remove_for_nsapp();
|
||||
}
|
||||
self.entries.clear();
|
||||
self.window = None;
|
||||
let menu = Menu::new();
|
||||
let mut special = Vec::new();
|
||||
for m in &bar.menus {
|
||||
let sub = Submenu::new(escape(&m.title), true);
|
||||
self.append(&sub, &m.children);
|
||||
let (sub, ids) = self.submenu(m);
|
||||
let _ = menu.append(&sub);
|
||||
if m.role == MenuRole::Window {
|
||||
self.window = Some((sub.clone(), ids, native_menu::window_key(bar)));
|
||||
}
|
||||
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
|
||||
special.push((m.role, sub));
|
||||
}
|
||||
@@ -97,6 +104,33 @@ impl MacMenu {
|
||||
self.structure = native_menu::structure_key(bar);
|
||||
}
|
||||
|
||||
/// A top-level menu, and the muda ids of the items it added.
|
||||
fn submenu(&mut self, m: &native_menu::Menu) -> (Submenu, Vec<String>) {
|
||||
let before: HashSet<String> = self.entries.keys().cloned().collect();
|
||||
let sub = Submenu::new(escape(&m.title), true);
|
||||
self.append(&sub, &m.children);
|
||||
let ids = self.entries.keys().filter(|id| !before.contains(*id)).cloned().collect();
|
||||
(sub, ids)
|
||||
}
|
||||
|
||||
/// Replace the Window menu (a document opened or closed), leaving the other menus as they are.
|
||||
fn rebuild_window(&mut self, bar: &MenuBar) {
|
||||
let (Some(menu), Some((old, ids, _))) = (self.menu.clone(), self.window.take()) else { return self.build(bar) };
|
||||
let Some((at, spec)) = bar.menus.iter().enumerate().find(|(_, m)| m.role == MenuRole::Window) else { return self.build(bar) };
|
||||
if menu.remove(&old).is_err() {
|
||||
return self.build(bar);
|
||||
}
|
||||
for id in ids {
|
||||
self.entries.remove(&id);
|
||||
}
|
||||
let (sub, ids) = self.submenu(spec);
|
||||
if menu.insert(&sub, at).is_err() {
|
||||
return self.build(bar);
|
||||
}
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
self.window = Some((sub, ids, native_menu::window_key(bar)));
|
||||
}
|
||||
|
||||
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
@@ -160,6 +194,9 @@ impl Backend for MacMenu {
|
||||
self.build(bar);
|
||||
return;
|
||||
}
|
||||
if self.window.as_ref().map(|w| w.2) != Some(native_menu::window_key(bar)) {
|
||||
self.rebuild_window(bar);
|
||||
}
|
||||
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
|
||||
for e in self.entries.values_mut() {
|
||||
let Some(it) = items.get(e.command.as_str()) else { continue };
|
||||
|
||||
@@ -29,6 +29,7 @@ mod caps_lock;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod file_dialog_probe;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
@@ -89,10 +90,25 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
// eframe saves native window geometry and egui panel/window sizes on exit.
|
||||
// Keep that state beside preferences, including config overrides and portable mode.
|
||||
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
|
||||
window_builder: Some(Box::new(at_least_min_size)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
|
||||
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
|
||||
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
|
||||
/// as a 160 × 64 strip on every launch.
|
||||
fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
|
||||
let (Some(size), Some(min)) = (builder.inner_size, builder.min_inner_size) else { return builder };
|
||||
let grown = size.max(min);
|
||||
if grown != size {
|
||||
builder.position = builder.position.map(|pos| pos - (grown - size) / 2.0);
|
||||
builder.inner_size = Some(grown);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
|
||||
/// names the value (issue #701): silently running with no control server leaves a launcher or
|
||||
/// agent unable to tell a typo from a successful grant.
|
||||
@@ -139,6 +155,8 @@ fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
|
||||
photocraft_ui_egui::type_tool::warm_fonts();
|
||||
let mut control_port: Option<u16> = None;
|
||||
let mut control_arg_errors: Vec<String> = Vec::new();
|
||||
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
|
||||
@@ -212,6 +230,8 @@ fn main() -> eframe::Result {
|
||||
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
|
||||
// app no pen input (#639).
|
||||
#[cfg(target_os = "linux")]
|
||||
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = tablet::display_session();
|
||||
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
|
||||
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
|
||||
@@ -279,6 +299,9 @@ fn main() -> eframe::Result {
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
#[cfg(target_os = "linux")]
|
||||
let monitor = monitor_profile::detect_async_for_session(native_session);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let monitor = monitor_profile::detect_async();
|
||||
// Brush presets load in the background; the app attaches them when they arrive.
|
||||
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
|
||||
@@ -342,6 +365,7 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
|
||||
file_dialog_probe::start();
|
||||
if services.is_wayland {
|
||||
let var = |name| std::env::var(name).ok();
|
||||
services.xwayland_command =
|
||||
@@ -360,7 +384,14 @@ fn main() -> eframe::Result {
|
||||
// the right profile; a slower reading is applied when it arrives, and the shell reads
|
||||
// again when the displays may have changed.
|
||||
if let Some(rx) = monitor {
|
||||
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session)));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
|
||||
}
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
|
||||
@@ -571,6 +602,28 @@ mod tests {
|
||||
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_saved_while_minimized_reopens_at_the_minimum_size() {
|
||||
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
|
||||
// Saved minimized on Windows, then centred on a 1920 × 1080 monitor by eframe.
|
||||
let saved = super::native_options(super::CUSTOM_TITLEBAR).viewport.with_inner_size([160.0, 64.0]).with_position([880.0, 508.0]);
|
||||
let opened = hook(saved);
|
||||
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 480.0)));
|
||||
assert_eq!(opened.position, Some(egui::pos2(580.0, 300.0)), "still centred");
|
||||
// Too narrow only: only the width grows.
|
||||
let opened = super::at_least_min_size(super::native_options(false).viewport.with_inner_size([500.0, 900.0]));
|
||||
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 900.0)));
|
||||
// A window at or above the minimum, or with no size, is left alone.
|
||||
for size in [[760.0, 480.0], [1100.0, 760.0]] {
|
||||
let builder = super::native_options(false).viewport.with_inner_size(size).with_position([10.0, 20.0]);
|
||||
let opened = super::at_least_min_size(builder);
|
||||
assert_eq!((opened.inner_size, opened.position), (Some(egui::Vec2::from(size)), Some(egui::pos2(10.0, 20.0))));
|
||||
}
|
||||
let mut builder = super::native_options(false).viewport;
|
||||
builder.inner_size = None;
|
||||
assert_eq!(super::at_least_min_size(builder).inner_size, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
|
||||
//! thread; the shell starts one at launch and again when the displays may have changed
|
||||
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
|
||||
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
|
||||
//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices
|
||||
//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has
|
||||
//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session,
|
||||
//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland
|
||||
//! image description, so applying the output ICC in PhotoCraft could double-transform it.
|
||||
//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
@@ -16,15 +21,33 @@ use photocraft_engine::display_color::Display;
|
||||
pub type Detection = Result<Vec<Display>, String>;
|
||||
|
||||
/// Starts reading the displays in the background; `None` when the platform has no reader.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn detect_async() -> Option<Receiver<Detection>> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return None;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Some(spawn_reader(detect))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux display profile reads are supported only for an X11 session. In particular, an app
|
||||
/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a
|
||||
/// compositor-managed output profile a second time.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option<Receiver<Detection>> {
|
||||
(session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
});
|
||||
Some(rx)
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
@@ -110,9 +133,225 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect() -> Detection {
|
||||
Err("this platform doesn't report display profiles".into())
|
||||
linux::detect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Read as _;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use photocraft_engine::display_color::Display;
|
||||
use x11rb::connection::Connection as _;
|
||||
use x11rb::protocol::randr;
|
||||
use zbus::blocking::{connection::Builder, proxy::Proxy};
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
use super::Detection;
|
||||
|
||||
const MAX_ICC_BYTES: usize = 64 * 1024 * 1024;
|
||||
const COLORD: &str = "org.freedesktop.ColorManager";
|
||||
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
|
||||
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
|
||||
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
|
||||
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
|
||||
|
||||
struct IccProfile {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub(super) fn detect() -> Detection {
|
||||
let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
|
||||
let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root;
|
||||
let resources = randr::get_screen_resources_current(&connection, root)
|
||||
.map_err(|e| format!("couldn't request XRandR outputs: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("couldn't read XRandR outputs: {e}"))?;
|
||||
|
||||
let mut output_names = HashMap::new();
|
||||
let mut active_crtcs = BTreeMap::new();
|
||||
for output in resources.outputs {
|
||||
let info = match randr::get_output_info(&connection, output, resources.config_timestamp) {
|
||||
Ok(cookie) => match cookie.reply() {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
log::debug!("couldn't read one XRandR output: {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::debug!("couldn't request one XRandR output: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = String::from_utf8(info.name) else {
|
||||
log::debug!("skipping an XRandR output with a non-UTF-8 name");
|
||||
continue;
|
||||
};
|
||||
if !name.is_empty() {
|
||||
output_names.insert(output, name);
|
||||
active_crtcs.insert(info.crtc, ());
|
||||
}
|
||||
}
|
||||
|
||||
let profiles = match colord_profiles() {
|
||||
Ok(profiles) => profiles,
|
||||
Err(e) => {
|
||||
log::warn!("couldn't read X11 display profiles from colord: {e}");
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
let mut displays = Vec::with_capacity(active_crtcs.len());
|
||||
for crtc in active_crtcs.into_keys() {
|
||||
let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) {
|
||||
Ok(cookie) => match cookie.reply() {
|
||||
Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
log::debug!("couldn't read XRandR CRTC {crtc}: {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::debug!("couldn't request XRandR CRTC {crtc}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::<Option<Vec<_>>>() else { continue };
|
||||
let Some(primary_name) = output_names.first() else { continue };
|
||||
let profile = unambiguous_clone_profile(&output_names, &profiles);
|
||||
displays.push(Display {
|
||||
id: crtc,
|
||||
name: primary_name.clone(),
|
||||
frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)],
|
||||
profile_name: profile.as_ref().map(|p| p.name.clone()),
|
||||
icc: profile.map(|p| p.bytes),
|
||||
});
|
||||
}
|
||||
if displays.is_empty() {
|
||||
return Err("X11 reported no active RandR outputs".into());
|
||||
}
|
||||
Ok(displays)
|
||||
}
|
||||
|
||||
fn colord_profiles() -> Result<HashMap<String, Vec<IccProfile>>, String> {
|
||||
let connection = Builder::system()
|
||||
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
|
||||
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?;
|
||||
let devices: Vec<OwnedObjectPath> =
|
||||
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?;
|
||||
let mut profiles: HashMap<String, Vec<IccProfile>> = HashMap::new();
|
||||
for device_path in devices {
|
||||
let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?;
|
||||
let metadata: HashMap<String, String> = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?;
|
||||
let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue };
|
||||
// `*` is colord's default profile qualifier. A display without one is kept, with no
|
||||
// ICC, so the engine reports the normal sRGB fallback.
|
||||
let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) {
|
||||
Ok(path) => path,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?;
|
||||
let filename: String = match profile.get_property::<String>("Filename") {
|
||||
Ok(filename) if !filename.is_empty() => filename,
|
||||
_ => continue,
|
||||
};
|
||||
let Some(profile) = read_icc(Path::new(&filename)) else {
|
||||
log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}");
|
||||
continue;
|
||||
};
|
||||
profiles.entry(output_name.clone()).or_default().push(profile);
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn read_icc(path: &Path) -> Option<IccProfile> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
|
||||
let bytes = checked_icc_bytes(bytes)?;
|
||||
let name = path.file_name()?.to_str()?.to_owned();
|
||||
Some(IccProfile { name, bytes: Arc::new(bytes) })
|
||||
}
|
||||
|
||||
fn checked_icc_bytes(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||||
if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
|
||||
return None;
|
||||
}
|
||||
let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize;
|
||||
if declared < 132 || declared > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
bytes.truncate(declared);
|
||||
let profile = photocraft_cms::Profile::parse(&bytes).ok()?;
|
||||
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
|
||||
}
|
||||
|
||||
fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap<String, Vec<IccProfile>>) -> Option<IccProfile> {
|
||||
let mut selected: Option<&IccProfile> = None;
|
||||
for output in output_names {
|
||||
let candidates = profiles.get(output)?;
|
||||
let first = candidates.first()?;
|
||||
if candidates.iter().any(|candidate| candidate.bytes != first.bytes) {
|
||||
return None;
|
||||
}
|
||||
if selected.is_some_and(|profile| profile.bytes != first.bytes) {
|
||||
return None;
|
||||
}
|
||||
selected = Some(first);
|
||||
}
|
||||
selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() {
|
||||
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
|
||||
let bytes = rgb.as_ref().clone();
|
||||
assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes));
|
||||
|
||||
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
|
||||
assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none());
|
||||
assert!(checked_icc_bytes(vec![0; 132]).is_none());
|
||||
assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_outputs_use_only_one_unambiguous_profile() {
|
||||
let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
|
||||
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes();
|
||||
let profile = |name: &str, bytes: std::sync::Arc<Vec<u8>>| IccProfile { name: name.into(), bytes };
|
||||
let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()];
|
||||
|
||||
let matching =
|
||||
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
|
||||
assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into()));
|
||||
|
||||
let differing =
|
||||
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]);
|
||||
assert!(unambiguous_clone_profile(&outputs, &differing).is_none());
|
||||
|
||||
let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
|
||||
assert!(unambiguous_clone_profile(&outputs, &missing).is_none());
|
||||
|
||||
let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]);
|
||||
assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
|
||||
@@ -285,6 +524,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn detected_displays_parse() {
|
||||
if let Ok(displays) = detect() {
|
||||
|
||||
@@ -48,6 +48,66 @@ fn count(width: usize, height: usize) -> Result<usize, String> {
|
||||
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024;
|
||||
#[cfg(target_os = "linux")]
|
||||
const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024;
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icc_profile(bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let profile = match photocraft_cms::Profile::parse(&bytes) {
|
||||
Ok(profile) => profile,
|
||||
Err(_) => return None,
|
||||
};
|
||||
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icc_profile_atom<C: x11rb::connection::RequestConnection>(conn: &C, name: &str) -> Option<u32> {
|
||||
use x11rb::protocol::xproto::ConnectionExt as _;
|
||||
let cookie = match conn.intern_atom(true, name.as_bytes()) {
|
||||
Ok(cookie) => cookie,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let atom = match cookie.reply() {
|
||||
Ok(reply) => reply.atom,
|
||||
Err(_) => return None,
|
||||
};
|
||||
(atom != 0).then_some(atom)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn output_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, output: u32, atom: u32) -> Option<Vec<u8>> {
|
||||
use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum};
|
||||
let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) {
|
||||
Ok(cookie) => cookie,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let reply = match cookie.reply() {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 {
|
||||
return None;
|
||||
}
|
||||
icc_profile(reply.data)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn root_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, root: u32, atom: u32) -> Option<Vec<u8>> {
|
||||
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
|
||||
let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) {
|
||||
Ok(cookie) => cookie,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let reply = match cookie.reply() {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if reply.format != 8 || reply.bytes_after != 0 {
|
||||
return None;
|
||||
}
|
||||
icc_profile(reply.value)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
if wayland {
|
||||
return portal(stop);
|
||||
@@ -55,10 +115,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
image::{Image, PixelLayout},
|
||||
protocol::{
|
||||
randr::ConnectionExt as _,
|
||||
xproto::{AtomEnum, ConnectionExt, VisualClass},
|
||||
},
|
||||
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
|
||||
};
|
||||
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
|
||||
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
|
||||
@@ -71,13 +128,16 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
{
|
||||
first.primary = true;
|
||||
}
|
||||
let regions: Vec<(i16, i16, u16, u16, bool)> =
|
||||
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
|
||||
let regions: Vec<(i16, i16, u16, u16, bool, Vec<x11rb::protocol::randr::Output>)> = if monitors.is_empty() {
|
||||
vec![(0, 0, w, h, true, Vec::new())]
|
||||
} else {
|
||||
monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect()
|
||||
};
|
||||
if regions.len() > 16 {
|
||||
return Err("Too many screens for the color picker".into());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for &(x, y, mw, mh, _) in ®ions {
|
||||
for &(x, y, mw, mh, _, _) in ®ions {
|
||||
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
|
||||
return Err("Invalid X11 display geometry".into());
|
||||
}
|
||||
@@ -92,20 +152,20 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
return Err("Screen picking requires an X11 TrueColor visual".into());
|
||||
}
|
||||
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
|
||||
let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
|
||||
let primary_profile = if atom == 0 {
|
||||
None
|
||||
} else {
|
||||
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
|
||||
};
|
||||
let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
|
||||
let mut images = Vec::new();
|
||||
for (x, y, mw, mh, primary) in regions {
|
||||
for (x, y, mw, mh, primary, outputs) in regions {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| {
|
||||
if !primary {
|
||||
return None;
|
||||
}
|
||||
icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom))
|
||||
});
|
||||
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
|
||||
for row in 0..mh {
|
||||
for col in 0..mw {
|
||||
@@ -120,7 +180,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
width: usize::from(mw),
|
||||
height: usize::from(mh),
|
||||
rgba,
|
||||
profile: if primary { primary_profile.clone() } else { None },
|
||||
profile,
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
@@ -359,6 +419,18 @@ mod tests {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() {
|
||||
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
|
||||
let bytes = rgb.as_ref().clone();
|
||||
assert_eq!(icc_profile(bytes.clone()), Some(bytes));
|
||||
|
||||
assert!(icc_profile(Vec::new()).is_none());
|
||||
assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none());
|
||||
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
|
||||
assert!(icc_profile(cmyk.as_ref().clone()).is_none());
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
|
||||
fn screen_capture_reads_a_known_x11_fixture_pixel() {
|
||||
use x11rb::{
|
||||
|
||||
@@ -21,8 +21,8 @@ mod recovery_tests;
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco",
|
||||
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -60,6 +60,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("FITS", &["fits", "fit"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
@@ -129,7 +130,11 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
}
|
||||
};
|
||||
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
|
||||
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || reply.send(block_on(answer))) {
|
||||
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// An empty answer from a system with no dialog backend isn't a Cancel (#3354).
|
||||
let answer = block_on(answer).or_else(|| crate::file_dialog_probe::known_problem().map(FileDialogAnswer::Unavailable));
|
||||
reply.send(answer)
|
||||
}) {
|
||||
log::error!("couldn't wait for the file dialog: {e}");
|
||||
}
|
||||
}
|
||||
@@ -186,6 +191,11 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
|
||||
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
|
||||
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
@@ -197,25 +207,47 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts.image_previews = settings.image_previews;
|
||||
opts
|
||||
}
|
||||
|
||||
/// A save on a worker thread (`Services::save_file`, #2017): encode, then write atomically.
|
||||
/// Cancelling while encoding leaves the file on disk untouched; once writing starts the save
|
||||
/// can't be cancelled.
|
||||
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
|
||||
/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush.
|
||||
const ENCODE_SHARE: f32 = 0.85;
|
||||
|
||||
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
|
||||
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
|
||||
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
|
||||
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
|
||||
fn save_through(
|
||||
doc: &Document,
|
||||
path: &str,
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
ctl.progress(0.0, "Encoding");
|
||||
let opts = export_options(settings);
|
||||
let cancelled = || ctl.check().is_err();
|
||||
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
|
||||
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
|
||||
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
|
||||
ctl.commit()?;
|
||||
ctl.progress(ENCODE_SHARE, "Writing");
|
||||
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
|
||||
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
|
||||
ctl.progress(1.0, "");
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
fn save_file(
|
||||
doc: &Document,
|
||||
path: &str,
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
) -> Result<Vec<String>, String> {
|
||||
ctl.progress(0.0, "Encoding");
|
||||
let opts = export_options(settings);
|
||||
let r = crate::crash_guard::guard("Save", || photocraft_io::export(doc, path, &opts).map_err(|e| e.to_string()))?;
|
||||
ctl.commit()?;
|
||||
ctl.progress(0.0, "Writing");
|
||||
write_atomic(Path::new(path), &r.bytes)?;
|
||||
Ok(r.warnings)
|
||||
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
|
||||
}
|
||||
|
||||
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
|
||||
@@ -315,6 +347,18 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let automation_write = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
|
||||
});
|
||||
let automation_open_pcraft = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
|
||||
let opened = crate::crash_guard::guard("Open", || workspace.open_pcraft(path).map_err(|e| e.to_string()))?;
|
||||
Ok(opened.map(|doc| (name, doc)))
|
||||
}) as photocraft_ui_egui::AutomationOpenPcraftFn
|
||||
});
|
||||
let automation_save = automation.clone().map(|workspace| {
|
||||
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
|
||||
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
|
||||
}) as photocraft_ui_egui::SaveFileFn
|
||||
});
|
||||
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
|
||||
let automation_authorize = automation.is_some().then_some(step);
|
||||
let automation_command = automation.map(|_| {
|
||||
@@ -337,6 +381,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_open_pcraft,
|
||||
automation_save,
|
||||
automation_command,
|
||||
automation_authorize,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
@@ -345,6 +391,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
})),
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
beep: Some(platform_beep),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
@@ -391,6 +438,21 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferences ▸ General ▸ Beep When Done: the platform's default information beep.
|
||||
fn platform_beep() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// No beep API among this app's other platforms' dependencies: write the terminal bell
|
||||
// (audible when launched from a terminal, which is the same cue macOS terminals use).
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
|
||||
#[allow(dead_code)]
|
||||
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
|
||||
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
|
||||
//!
|
||||
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
|
||||
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included.
|
||||
//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the
|
||||
//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one
|
||||
//! under ExplorerPatcher (#3144). The guard therefore also trims a parked maximize's rect back to
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
@@ -34,8 +42,8 @@ mod platform {
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
strip(&hwnd);
|
||||
Some(Box::new(move || strip(&hwnd)))
|
||||
reapply(&hwnd);
|
||||
Some(Box::new(move || reapply(&hwnd)))
|
||||
}
|
||||
|
||||
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
|
||||
@@ -53,6 +61,13 @@ mod platform {
|
||||
found
|
||||
}
|
||||
|
||||
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
|
||||
/// maximize stays clear of the taskbar.
|
||||
fn reapply(hwnd: &HWND) {
|
||||
strip(hwnd);
|
||||
keep_clear_of_the_taskbar(hwnd);
|
||||
}
|
||||
|
||||
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
|
||||
/// are gone, so it is cheap to call every frame.
|
||||
fn strip(hwnd: &HWND) {
|
||||
@@ -70,6 +85,51 @@ mod platform {
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
|
||||
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
|
||||
/// parked but being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
|
||||
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
}
|
||||
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
|
||||
return;
|
||||
};
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE { cx: r.right - r.left, cy: height },
|
||||
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
|
||||
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
|
||||
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
|
||||
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
|
||||
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
|
||||
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, _) = monitor;
|
||||
let (_, _, _, wb) = work;
|
||||
let side = ml - l;
|
||||
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
|
||||
return None;
|
||||
}
|
||||
if b <= wb + side {
|
||||
return None;
|
||||
}
|
||||
Some(wb + side - t)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
@@ -79,7 +139,7 @@ mod platform {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::borderless;
|
||||
use super::{borderless, clamped_height};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
@@ -94,6 +154,37 @@ mod platform {
|
||||
// Already borderless: unchanged, so the per-frame call does nothing.
|
||||
assert_eq!(bare.raw(), borderless(bare).raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
|
||||
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
|
||||
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
|
||||
// plus the frame the window was parked with.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
|
||||
// What Windows sizes a framed maximize to: the work area plus the frame.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
|
||||
// Taskbar on the left: the work area starts further right, but the monitor's bottom
|
||||
// is still clear, so the rect reaches nothing.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_away_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,31 @@ fn main() {
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
|
||||
|
||||
// Window ends with the open documents (#2991). Opening one rebuilds only the Window menu,
|
||||
// which AppKit still knows as its Window menu; choosing a row brings that document forward.
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).expect("a second document");
|
||||
frame(&mut app, egui::RawInput::default());
|
||||
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "a new document rebuilt only the Window menu");
|
||||
let window = submenu(&main_menu(), "Window");
|
||||
assert!(nsapp.windowsMenu().is_some_and(|w| Retained::as_ptr(&w) == Retained::as_ptr(&window)), "AppKit knows the new Window menu");
|
||||
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
|
||||
// The last rows (both documents are "Untitled").
|
||||
let rows = [window.numberOfItems() - 2, window.numberOfItems() - 1];
|
||||
let shown: Vec<String> = rows.iter().filter_map(|&i| window.itemAtIndex(i)).map(|it| it.title().to_string()).collect();
|
||||
assert_eq!(shown, names, "in tab order, after Bring All to Front: {:?}", titles(&window));
|
||||
let checked = |menu: &NSMenu, at: isize| menu.itemAtIndex(at).is_some_and(|it| it.state() == 1);
|
||||
assert!(!checked(&window, rows[0]) && checked(&window, rows[1]), "the active document is checked");
|
||||
dequeue(&other());
|
||||
window.performActionForItemAtIndex(rows[0]);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.session.active_index(), Some(0), "choosing the first row brought it forward");
|
||||
let window = submenu(&main_menu(), "Window");
|
||||
assert!(checked(&window, rows[0]) && !checked(&window, rows[1]), "the check moved in place");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Artboard tool glyph (not from an icon set): a board with a tick at each end of its edges. -->
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
<path d="M7 2v2.5M17 2v2.5M7 19.5V22M17 19.5V22" />
|
||||
<path d="M2 7h2.5M2 17h2.5M19.5 7H22M19.5 17H22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 10 3 3" />
|
||||
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
|
||||
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
|
||||
<path d="M2.5 9A6 6 0 0 1 8 3" />
|
||||
<path d="m6 1.5 2 1.5-2 1.5" />
|
||||
<path d="M21.5 15a6 6 0 0 1-5.5 6" />
|
||||
<path d="m18 19.5-2 1.5 2 1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Convert Point glyph: a corner point, an angle with a square anchor on its apex. -->
|
||||
<rect x="9.75" y="4.75" width="4.5" height="4.5" />
|
||||
<path d="M10.5 9.25 4 19" />
|
||||
<path d="M13.5 9.25 20 19" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Frame tool glyph (not from an icon set): an empty placeholder frame, crossed corner to corner. -->
|
||||
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||
<path d="M6.5 7.5l11 9M17.5 7.5l-11 9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
@@ -0,0 +1,18 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
<path d="M18 3.5h5" />
|
||||
<path d="M20.5 1v5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,21 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
</g>
|
||||
<path d="M3 12C3 7 7 3 12 3" />
|
||||
<circle cx="3" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="3" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 765 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
<path d="M18 3.5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
</g>
|
||||
<path d="M8.5 8.5C6 6 7.5 3 5 3S2 6 4 7.5 5.5 11 3 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Perspective Crop tool glyph (not from an icon set): a crop frame seen in
|
||||
perspective, with its grid and two crop-mark ticks. -->
|
||||
<path d="M8 6h8l4 12H4z" />
|
||||
<path d="M12 6v12" />
|
||||
<path d="M6 12h12" />
|
||||
<path d="M8 2v2" />
|
||||
<path d="M20 22v-2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="1.4" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Vertical Type Mask glyph (not from an icon set): a T drawn as marching
|
||||
ants beside a downward arrow (the text runs top to bottom). -->
|
||||
<path d="M2.5 7V4h12v3" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M8.5 4v16" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M6 20h5" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M20 8v12" />
|
||||
<path d="M17.5 17.5 20 20l2.5-2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Horizontal Type Mask glyph (not from an icon set): a T drawn as marching ants. -->
|
||||
<path d="M4 7V4h16v3" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M12 4v16" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M8.5 20h7" stroke-dasharray="2.5 2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -18,6 +18,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
|
||||
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
|
||||
- Headless TCP refuses a successfully bound non-loopback address.
|
||||
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
|
||||
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
|
||||
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
|
||||
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
|
||||
are limited to 16, and headless/MCP batches to 256 steps.
|
||||
- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless
|
||||
JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is
|
||||
enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because
|
||||
its request ID cannot safely be parsed. Active TCP connections are limited to 16, and
|
||||
headless/MCP batches to 256 steps.
|
||||
- Capability-scoped automation file opens read in chunks without reserving from the file's
|
||||
metadata length. A read rejects data beyond the length observed on the opened file, so growth
|
||||
after the metadata check cannot expand the read. Large PSB files have no fixed automation file
|
||||
size ceiling; their materialized bytes and decoded document still require available memory.
|
||||
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
|
||||
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
|
||||
transport enforces the same request/reply ceilings as TCP.
|
||||
@@ -19,7 +24,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
|
||||
retains its existing behavior.
|
||||
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
|
||||
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
|
||||
reply. Edits are never automatically resent after a transport failure (#1007); the server's
|
||||
pure-read tools opt into a single retry on a fresh connection, which applies nothing.
|
||||
Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
|
||||
downscaling; the 5 MiB PNG limit applies to the returned image.
|
||||
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
|
||||
- The bridge and UI request paths use timeouts.
|
||||
@@ -47,8 +54,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- no general per-client or per-tool capability model beyond filesystem read/write authority;
|
||||
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
|
||||
`prefs.set`;
|
||||
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
|
||||
scratch-space accounting, or command-duration/cancellation budget;
|
||||
- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion
|
||||
limit), aggregate document/session-memory accounting, compositor scratch-space accounting,
|
||||
or command-duration/cancellation budget;
|
||||
- one thread per accepted TCP connection;
|
||||
- no structured security audit event stream;
|
||||
- desktop screenshot capture/encoding and document import/export have no automation-specific
|
||||
|
||||
@@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem
|
||||
|
||||
### Native format
|
||||
|
||||
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
|
||||
`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
|
||||
|
||||
## Review checklist
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.Skyfuller1303]
|
||||
display_name = "Skyfuller"
|
||||
sync_github_name = true
|
||||
|
||||
[people.AlanRockefeller]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -93,3 +97,15 @@ sync_github_name = true
|
||||
|
||||
[people.FinnNk]
|
||||
sync_github_name = true
|
||||
|
||||
[people.TheTeslak]
|
||||
sync_github_name = true
|
||||
|
||||
[people.timothydaly1]
|
||||
display_name = "SyntaxError"
|
||||
|
||||
[people.axlecoffee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Full filter timing, including tile reads, filtering, writeback and pruning.
|
||||
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_algorithms -- extrude 6000 4000 u8 3 output.bin
|
||||
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
|
||||
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
|
||||
use photocraft_algo::{FilterParams, apply_in};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let case = args.first().map(String::as_str).unwrap_or("extrude");
|
||||
let parse = |i: usize, fallback: i32| -> Result<i32, Box<dyn std::error::Error>> {
|
||||
Ok(match args.get(i) {
|
||||
Some(s) => s.parse()?,
|
||||
None => fallback,
|
||||
})
|
||||
};
|
||||
let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?);
|
||||
if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) {
|
||||
return Err("dimensions must be 1..10000, repeats 1..9".into());
|
||||
}
|
||||
let depth = match args.get(3).map(String::as_str).unwrap_or("u8") {
|
||||
"u8" => SampleType::U8,
|
||||
"u16" => SampleType::U16,
|
||||
"f32" => SampleType::F32,
|
||||
_ => return Err("depth must be u8, u16 or f32".into()),
|
||||
};
|
||||
let extrude = |size, depth| FilterParams::Extrude {
|
||||
kind: photocraft_algo::ExtrudeType::Blocks,
|
||||
size,
|
||||
depth,
|
||||
level_based: false,
|
||||
solid_front: false,
|
||||
mask_incomplete: false,
|
||||
seed: 17,
|
||||
};
|
||||
let params = match case {
|
||||
"extrude" => extrude(30.0, 30.0),
|
||||
"extrude-dense" => extrude(8.0, 128.0),
|
||||
"extrude-small" => extrude(8.0, 64.0),
|
||||
"wind" => FilterParams::Wind { method: photocraft_algo::WindMethod::Wind, from_right: false, seed: 17 },
|
||||
"blast" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: false, seed: 17 },
|
||||
"blast-right" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: true, seed: 17 },
|
||||
"spin" => FilterParams::SpinBlur {
|
||||
pins: vec![photocraft_algo::SpinPin { radius_x: 1.0, radius_y: 1.0, blur_angle: 30.0, angle: 17.0, ..Default::default() }],
|
||||
},
|
||||
"spin-default" => FilterParams::SpinBlur { pins: vec![Default::default()] },
|
||||
"lens" | "lens-ca" | "trap" | "trap-wide" | "trap-default" => FilterParams::Invert,
|
||||
_ => return Err("unknown case".into()),
|
||||
};
|
||||
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
|
||||
"rgb" => ColorMode::Rgb,
|
||||
"gray" => ColorMode::Grayscale,
|
||||
"cmyk" => ColorMode::Cmyk,
|
||||
"lab" => ColorMode::Lab,
|
||||
_ => return Err("mode must be rgb, gray, cmyk or lab".into()),
|
||||
};
|
||||
if case.starts_with("trap") && mode != ColorMode::Cmyk {
|
||||
return Err("Trap needs CMYK samples".into());
|
||||
}
|
||||
let format = PixelFormat::new(mode, depth, true);
|
||||
let n = format.channels();
|
||||
let bounds = Rect::new(-17, -29, w - 17, h - 29);
|
||||
let mut source = Surface::new(format);
|
||||
for tile in bounds.tiles() {
|
||||
let r = tile.rect().intersect(&bounds);
|
||||
let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
|
||||
for c in 0..n {
|
||||
let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536;
|
||||
pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
source.write_region(r, &pixels);
|
||||
}
|
||||
let run_filter = |area: Rect| match case {
|
||||
"lens" | "lens-ca" => photocraft_algo::lens::correct(
|
||||
&source,
|
||||
area,
|
||||
&photocraft_algo::lens::LensCorrection {
|
||||
distortion: 12.0,
|
||||
angle: 2.5,
|
||||
vignette_amount: 20.0,
|
||||
red_cyan: if case == "lens-ca" { 12.0 } else { 0.0 },
|
||||
blue_yellow: if case == "lens-ca" { -8.0 } else { 0.0 },
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
"trap" | "trap-wide" | "trap-default" => {
|
||||
let mut data = source.read_region(area);
|
||||
photocraft_algo::trap::trap(
|
||||
&mut data,
|
||||
area.width() as usize,
|
||||
area.height() as usize,
|
||||
n,
|
||||
if case == "trap-wide" {
|
||||
50
|
||||
} else if case == "trap-default" {
|
||||
1
|
||||
} else {
|
||||
10
|
||||
},
|
||||
);
|
||||
let mut result = source.clone();
|
||||
result.write_region(area, &data);
|
||||
result.prune();
|
||||
result
|
||||
}
|
||||
_ => apply_in(&source, ¶ms, area, bounds, None, bounds),
|
||||
};
|
||||
let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128));
|
||||
drop(run_filter(warm));
|
||||
let mut times = Vec::new();
|
||||
for run in 0..repeats {
|
||||
let start = Instant::now();
|
||||
let result = run_filter(bounds);
|
||||
let seconds = start.elapsed().as_secs_f64();
|
||||
println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1);
|
||||
times.push(seconds);
|
||||
if run + 1 == repeats
|
||||
&& let Some(path) = args.get(5)
|
||||
{
|
||||
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
|
||||
for tile in bounds.tiles() {
|
||||
for sample in result.read_region(tile.rect().intersect(&bounds)) {
|
||||
file.write_all(&sample.to_bits().to_le_bytes())?;
|
||||
}
|
||||
}
|
||||
file.flush()?;
|
||||
}
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
if let Some(median) = times.get(times.len() / 2) {
|
||||
println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Complete Tilt-Shift filtering, including tiled reads and writes.
|
||||
//! Run alone in release: `bench_tilt_shift 6000 4000 5 1`.
|
||||
//! Fourth argument models the existing preview proxy scale (e.g. 1500 1000 7 4).
|
||||
use photocraft_algo::{FilterParams, apply_in};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let number = |i: usize, fallback| args.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(fallback);
|
||||
let (w, h, reps) = (number(1, 6000).clamp(1, 6000), number(2, 4000).clamp(1, 6000), number(3, 5).clamp(1, 20));
|
||||
let proxy = number(4, 1).clamp(1, 8) as f32;
|
||||
let bounds = Rect::new(0, 0, w, h);
|
||||
for (sample, name) in [(SampleType::U8, "U8"), (SampleType::U16, "U16"), (SampleType::F32, "F32")] {
|
||||
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
|
||||
for y in 0..h {
|
||||
let row: Vec<f32> = (0..w)
|
||||
.flat_map(|x| {
|
||||
let a = if (x + y) % 31 == 0 { 0.0 } else { 0.7 + (x % 7) as f32 / 30.0 };
|
||||
let hdr = if sample == SampleType::F32 { 3.0 } else { 1.0 };
|
||||
[hdr * (x % 256) as f32 / 255.0, ((x * 3 + y) % 251) as f32 / 250.0, ((x ^ y) % 256) as f32 / 255.0, a]
|
||||
})
|
||||
.collect();
|
||||
source.write_region(Rect::new(0, y, w, y + 1), &row);
|
||||
}
|
||||
let mut cases = vec![("medium", 80.0, 0.5, 0.5, 27.0, 0.1, 0.15)];
|
||||
if sample == SampleType::U8 {
|
||||
cases.extend([
|
||||
("small", 15.0, 0.5, 0.5, 0.0, 0.1, 0.15),
|
||||
("large", 300.0, 0.5, 0.5, -71.0, 0.03, 0.4),
|
||||
("wide_focus", 80.0, 0.5, 0.5, 0.0, 0.4, 0.05),
|
||||
("sharp_transition", 80.0, 0.2, 0.8, 63.0, 0.05, 0.01),
|
||||
("focused", 80.0, 0.5, 0.5, 0.0, 1.0, 0.15),
|
||||
("blurred", 80.0, -5.0, -5.0, 27.0, 0.1, 0.15),
|
||||
]);
|
||||
}
|
||||
for (label, blur, center_x, center_y, angle, focus, transition) in cases {
|
||||
let params = FilterParams::TiltShift { blur: blur / proxy, center_x, center_y, angle, focus, transition };
|
||||
for _ in 0..2 {
|
||||
std::hint::black_box(apply_in(&source, ¶ms, bounds, bounds, None, bounds));
|
||||
}
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let start = std::time::Instant::now();
|
||||
let output = apply_in(&source, ¶ms, bounds, bounds, None, bounds);
|
||||
times.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(&output);
|
||||
drop(output);
|
||||
}
|
||||
println!("{}", serde_json::json!({"width": w, "height": h, "depth": name, "case": label, "proxy": proxy, "ms": times}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,12 @@
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use photocraft_raster::{Cancelled, Interrupt};
|
||||
|
||||
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked, complete_masked_owned};
|
||||
use crate::par;
|
||||
use crate::poisson::membrane_fill_with;
|
||||
|
||||
/// Options for [`fill`].
|
||||
@@ -143,7 +148,8 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
|
||||
}
|
||||
|
||||
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
|
||||
/// `Err` when cancelled.
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
||||
let Src { w, h, ch, .. } = src;
|
||||
let (s, c) = (-deg.to_radians()).sin_cos();
|
||||
// Output size: the rotated, scaled bounding box.
|
||||
@@ -153,8 +159,12 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
|
||||
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
|
||||
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
|
||||
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
|
||||
let mut px = vec![0.0f32; ch];
|
||||
for y in 0..oh {
|
||||
// Every pixel is sampled on its own, so the rows can be done in any order.
|
||||
let row = |y: usize, irow: &mut [f32], orow: &mut [bool]| {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
let mut px = vec![0.0f32; ch];
|
||||
for x in 0..ow {
|
||||
let (dx, dy) = ((x as f32 - ocx) / scale, (y as f32 - ocy) / scale);
|
||||
let mut sx = icx + dx * c - dy * s;
|
||||
@@ -163,13 +173,14 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
|
||||
sx = (w as f32 - 1.0) - sx;
|
||||
}
|
||||
if sample(src, sx, sy, &mut px) {
|
||||
let i = y * ow + x;
|
||||
out.ok[i] = true;
|
||||
out.img[i * ch..(i + 1) * ch].copy_from_slice(&px);
|
||||
orow[x] = true;
|
||||
irow[x * ch..(x + 1) * ch].copy_from_slice(&px);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
par::chunks2_mut(&mut out.img, ow * ch, &mut out.ok, ow, true, row);
|
||||
ctl.check()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole).
|
||||
@@ -191,81 +202,136 @@ pub fn fill_with(
|
||||
source: &[bool],
|
||||
opts: &FillOptions,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
|
||||
fill_cow(w, h, ch, Cow::Borrowed(img), hole, source, opts, ctl)
|
||||
}
|
||||
|
||||
/// [`fill_with`] that fills `img` itself rather than a copy of it (a big window need not be held
|
||||
/// twice). The result differs from `img` only in the hole.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill_owned(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: Vec<f32>,
|
||||
hole: &[bool],
|
||||
source: &[bool],
|
||||
opts: &FillOptions,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
|
||||
fill_cow(w, h, ch, Cow::Owned(img), hole, source, opts, ctl)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fill_cow(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: Cow<[f32]>,
|
||||
hole: &[bool],
|
||||
source: &[bool],
|
||||
opts: &FillOptions,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
|
||||
assert_eq!(img.len(), w * h * ch);
|
||||
assert_eq!(hole.len(), w * h);
|
||||
assert_eq!(source.len(), w * h);
|
||||
if !hole.iter().any(|h| *h) {
|
||||
return Ok(img.to_vec());
|
||||
return Ok(img.into_owned());
|
||||
}
|
||||
let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() };
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
let ok: Vec<bool> = hole.iter().zip(source).map(|(h, s)| !h && *s).collect();
|
||||
let src = Src { w, h, ch, img, ok: &ok };
|
||||
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
|
||||
let src = Src { w, h, ch, img: &img, ok: &ok };
|
||||
// Variants: the identity (which holds the hole) first, then the adaptations.
|
||||
let mut extra: Vec<Variant> = Vec::new();
|
||||
if opts.mirror {
|
||||
extra.push(variant(src, 0.0, 1.0, true));
|
||||
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
|
||||
}
|
||||
for ° in &opts.rotations {
|
||||
extra.push(variant(src, deg, 1.0, false));
|
||||
extra.push(variant(src, deg, 1.0, false, ctl)?);
|
||||
}
|
||||
if opts.scale {
|
||||
extra.push(variant(src, 0.0, 0.8, false));
|
||||
extra.push(variant(src, 0.0, 1.25, false));
|
||||
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
|
||||
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
|
||||
}
|
||||
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
|
||||
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
|
||||
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
|
||||
let mw = if extra.is_empty() { w } else { w + gap + col_w };
|
||||
let mh = h.max(col_h);
|
||||
let mut mimg = vec![0.0f32; mw * mh * ch];
|
||||
let mut roles = vec![Px::Void; mw * mh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (s, d) = (y * w + x, y * mw + x);
|
||||
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
|
||||
roles[d] = if hole[s] {
|
||||
Px::Hole
|
||||
} else if ok[s] {
|
||||
Px::Source
|
||||
} else {
|
||||
Px::Known
|
||||
};
|
||||
let role = |s: usize| {
|
||||
if hole[s] {
|
||||
Px::Hole
|
||||
} else if ok[s] {
|
||||
Px::Source
|
||||
} else {
|
||||
Px::Known
|
||||
}
|
||||
}
|
||||
let mut oy = 0;
|
||||
for v in &extra {
|
||||
for y in 0..v.h {
|
||||
for x in 0..v.w {
|
||||
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
|
||||
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
|
||||
if v.ok[s] {
|
||||
roles[d] = Px::Source;
|
||||
}
|
||||
};
|
||||
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
|
||||
// Without variants it is the image itself.
|
||||
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
|
||||
(w, h, None, par::map(w * h, true, role))
|
||||
} else {
|
||||
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
|
||||
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
|
||||
let mw = w + gap + col_w;
|
||||
let mh = h.max(col_h);
|
||||
let mut mimg = vec![0.0f32; mw * mh * ch];
|
||||
let mut roles = vec![Px::Void; mw * mh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (s, d) = (y * w + x, y * mw + x);
|
||||
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
|
||||
roles[d] = role(s);
|
||||
}
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
let mut oy = 0;
|
||||
for v in &extra {
|
||||
ctl.check()?;
|
||||
for y in 0..v.h {
|
||||
for x in 0..v.w {
|
||||
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
|
||||
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
|
||||
if v.ok[s] {
|
||||
roles[d] = Px::Source;
|
||||
}
|
||||
}
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
(mw, mh, Some(mimg), roles)
|
||||
};
|
||||
drop((extra, ok));
|
||||
ctl.check()?;
|
||||
// The completion is nearly all the work.
|
||||
let cancel = || ctl.cancelled();
|
||||
let progress = |f: f32| ctl.progress(f * 0.99);
|
||||
let filled = complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
|
||||
let mut out = img.to_vec();
|
||||
match filled {
|
||||
Some(f) => {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if hole[y * w + x] {
|
||||
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
|
||||
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
|
||||
}
|
||||
}
|
||||
let inner = photocraft_raster::Interrupt::new(&cancel, &progress);
|
||||
let out = match mosaic {
|
||||
// Without variants the image itself is completed (it changes only in the hole).
|
||||
None => {
|
||||
let filled: Result<Vec<f32>, Cow<[f32]>> = match img {
|
||||
Cow::Owned(v) => complete_masked_owned(w, h, ch, v, &roles, ¶ms, &inner)?.map_err(Cow::Owned),
|
||||
Cow::Borrowed(b) => complete_masked(w, h, ch, b, &roles, ¶ms, &inner)?.ok_or(Cow::Borrowed(b)),
|
||||
};
|
||||
match filled {
|
||||
Ok(f) => f,
|
||||
Err(img) => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
|
||||
}
|
||||
}
|
||||
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
|
||||
}
|
||||
Some(mimg) => match complete_masked(mw, mh, ch, &mimg, &roles, ¶ms, &inner)? {
|
||||
Some(f) => {
|
||||
let mut out = img.into_owned();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if hole[y * w + x] {
|
||||
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
|
||||
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
None => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
|
||||
},
|
||||
};
|
||||
ctl.check()?;
|
||||
ctl.progress(1.0);
|
||||
Ok(out)
|
||||
@@ -305,36 +371,50 @@ const AUTO_SIDE: usize = 384;
|
||||
/// pixels that may be copied from (never the hole). Falls back to everything outside the hole
|
||||
/// when the area would be too small to copy from.
|
||||
pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec<bool> {
|
||||
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with};
|
||||
// Never cancelled; the fallback (everything outside the hole) is unreachable.
|
||||
auto_sampling_with(w, h, ch, img, hole, &Interrupt::NONE).unwrap_or_else(|_| hole.iter().map(|h| !h).collect())
|
||||
}
|
||||
|
||||
/// [`auto_sampling`] that can be cancelled. `Err(Cancelled)` when `ctl` was cancelled.
|
||||
pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], ctl: &Interrupt) -> Result<Vec<bool>, Cancelled> {
|
||||
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_cancellable};
|
||||
assert_eq!(img.len(), w * h * ch);
|
||||
assert_eq!(hole.len(), w * h);
|
||||
let everything: Vec<bool> = hole.iter().map(|h| !h).collect();
|
||||
let everything = || -> Vec<bool> { par::map(hole.len(), true, |i| !hole[i]) };
|
||||
if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) {
|
||||
return everything;
|
||||
return Ok(everything());
|
||||
}
|
||||
// Work at a reduced scale for big windows (the area is smooth at this scale).
|
||||
let k = w.max(h).div_ceil(AUTO_SIDE).max(1);
|
||||
let (sw, sh) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let nc = ch.min(3);
|
||||
let mut planes = vec![vec![0.0f32; sw * sh]; nc];
|
||||
let mut shole = vec![false; sw * sh];
|
||||
let mut cnt = vec![0.0f32; sw * sh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y / k) * sw + x / k;
|
||||
let p = y * w + x;
|
||||
if hole[p] {
|
||||
shole[i] = true;
|
||||
}
|
||||
for (c, pl) in planes.iter_mut().enumerate() {
|
||||
pl[i] += img[p * ch + c];
|
||||
}
|
||||
cnt[i] += 1.0;
|
||||
// Each cell sums its k × k pixels, a row of cells at a time (in the same order as one pass
|
||||
// over the image would).
|
||||
// Per cell: the sums of its colour planes, its pixel count and whether it holds the hole.
|
||||
let mut sums = vec![0.0f32; sw * sh * (nc + 2)];
|
||||
par::chunks_mut(&mut sums, sw * (nc + 2), true, |sy, row| {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for pl in &mut planes {
|
||||
pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0));
|
||||
}
|
||||
for y in sy * k..((sy + 1) * k).min(h) {
|
||||
for x in 0..w {
|
||||
let cell = &mut row[(x / k) * (nc + 2)..(x / k + 1) * (nc + 2)];
|
||||
let p = y * w + x;
|
||||
if hole[p] {
|
||||
cell[nc + 1] = 1.0;
|
||||
}
|
||||
for c in 0..nc {
|
||||
cell[c] += img[p * ch + c];
|
||||
}
|
||||
cell[nc] += 1.0;
|
||||
}
|
||||
}
|
||||
});
|
||||
ctl.check()?;
|
||||
let cell = |i: usize| &sums[i * (nc + 2)..(i + 1) * (nc + 2)];
|
||||
let planes: Vec<Vec<f32>> = (0..nc).map(|c| (0..sw * sh).map(|i| cell(i)[c] / cell(i)[nc].max(1.0)).collect()).collect();
|
||||
let shole: Vec<bool> = (0..sw * sh).map(|i| cell(i)[nc + 1] > 0.0).collect();
|
||||
drop(sums);
|
||||
// Features: local means and local contrast (of the luma-ish average of the colour planes).
|
||||
let r = 3;
|
||||
let means: Vec<Vec<f32>> = planes.iter().map(|p| box_mean(sw, sh, p, r)).collect();
|
||||
@@ -354,8 +434,9 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
|
||||
let ring_w = 6.0f32 / k as f32 + 1.0;
|
||||
let ring: Vec<usize> = (0..sw * sh).filter(|&i| !shole[i] && dist[i] <= ring_w).collect();
|
||||
if ring.is_empty() {
|
||||
return everything;
|
||||
return Ok(everything());
|
||||
}
|
||||
ctl.check()?;
|
||||
// A few cluster centres of the ring's features (k-means, deterministic start).
|
||||
let kc = 6.min(ring.len());
|
||||
let mut f = vec![0.0f32; nf];
|
||||
@@ -410,20 +491,21 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
|
||||
// Contrast-sensitive smoothness (the weight of a squared colour difference). The scale of
|
||||
// "similar" and this weight were fitted to Photoshop's Auto overlays (mean IoU 0.92 on ten
|
||||
// synthetic and public-domain cases).
|
||||
let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05);
|
||||
ctl.check()?;
|
||||
let area =
|
||||
grid_cut_cancellable(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05, &|| ctl.cancelled()).ok_or(Cancelled)?;
|
||||
let mut out = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
out[i] = !hole[i] && area[(y / k) * sw + x / k];
|
||||
par::chunks_mut(&mut out, w, true, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = !hole[y * w + x] && area[(y / k) * sw + x / k];
|
||||
}
|
||||
}
|
||||
});
|
||||
// Too little to copy from (fewer source pixels than twice the hole): sample everything.
|
||||
let n_hole = hole.iter().filter(|h| **h).count();
|
||||
if out.iter().filter(|o| **o).count() < 2 * n_hole.max(64) {
|
||||
return everything;
|
||||
let n_hole = par::count(hole.len(), |i| hole[i]);
|
||||
if par::count(out.len(), |i| out[i]) < 2 * n_hole.max(64) {
|
||||
return Ok(everything());
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
|
||||
@@ -497,6 +579,36 @@ mod tests {
|
||||
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filling_in_place_is_filling_a_copy() {
|
||||
let (w, h) = (90, 70);
|
||||
let rgb: Vec<f32> = noise(w, h, 4).iter().enumerate().map(|(i, v)| v * 0.5 + 0.3 * ((i / 3 % w) % 11 < 5) as u8 as f32).collect();
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| (30..52).contains(&(i % w)) && (25..44).contains(&(i / w))).collect();
|
||||
let source: Vec<bool> = (0..w * h).map(|i| i % w < 75).collect();
|
||||
let none = Interrupt::NONE;
|
||||
for opts in [FillOptions::default(), FillOptions { color_adaptation: Adapt::NONE, mirror: true, ..Default::default() }] {
|
||||
let copy = fill_with(w, h, 3, &rgb, &hole, &source, &opts, &none).unwrap();
|
||||
let owned = fill_owned(w, h, 3, rgb.clone(), &hole, &source, &opts, &none).unwrap();
|
||||
assert_eq!(copy.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), owned.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
|
||||
// Only the hole changes.
|
||||
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| owned[i * 3 + c].to_bits() == rgb[i * 3 + c].to_bits())));
|
||||
}
|
||||
// Nothing to copy from: the membrane, either way.
|
||||
let nowhere = vec![false; w * h];
|
||||
let copy = fill_with(w, h, 3, &rgb, &hole, &nowhere, &FillOptions::default(), &none).unwrap();
|
||||
assert_eq!(copy, fill_owned(w, h, 3, rgb.clone(), &hole, &nowhere, &FillOptions::default(), &none).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sampling_can_be_cancelled() {
|
||||
let (w, h) = (200, 160);
|
||||
let img: Vec<f32> = noise(w, h, 5).iter().enumerate().map(|(i, v)| v * 0.1 + if (i / 3) % w < 120 { 0.3 } else { 0.6 }).collect();
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| (80..110).contains(&(i % w)) && (60..90).contains(&(i / w))).collect();
|
||||
let yes = || true;
|
||||
assert!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::cancel_only(&yes)).is_err());
|
||||
assert_eq!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::NONE).unwrap(), auto_sampling(w, h, 3, &img, &hole));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_only_the_hole_with_known_texture() {
|
||||
let (w, h) = (48, 32);
|
||||
@@ -671,9 +783,9 @@ mod tests {
|
||||
let img = vec![0.5f32; 10 * 6];
|
||||
let ok = vec![true; 60];
|
||||
let src = Src { w: 10, h: 6, ch: 1, img: &img, ok: &ok };
|
||||
let v = variant(src, 90.0, 1.0, false);
|
||||
let v = variant(src, 90.0, 1.0, false, &Interrupt::NONE).unwrap();
|
||||
assert_eq!((v.w, v.h), (6, 10));
|
||||
let v = variant(src, 0.0, 0.8, true);
|
||||
let v = variant(src, 0.0, 0.8, true, &Interrupt::NONE).unwrap();
|
||||
assert_eq!((v.w, v.h), (8, 5));
|
||||
assert!(v.ok.iter().filter(|o| **o).count() > 20);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
//! Direct (intensity-based) registration for Edit › Auto-Align Layers, for layers that nearly
|
||||
//! coincide: a focus bracket, an exposure bracket, a burst. Feature matching
|
||||
//! ([`crate::panorama`]) wants distinctive corners seen from two places; such layers are the same
|
||||
//! view with a sub-pixel shift, a little scale (focus breathing) and rotation, and much of each
|
||||
//! layer defocused, so there is often nothing to match and nothing to verify. Here the whole
|
||||
//! overlap is the evidence: the pose that makes the warped target look like the reference.
|
||||
//!
|
||||
//! The target is registered to the reference coarse-to-fine on a Gaussian pyramid of the
|
||||
//! luminance. The cost of a pose is the RMS of the difference between the reference and the
|
||||
//! target warped by it, the mean difference removed (so exposure and tone changes don't count),
|
||||
//! over the pixels both cover. Each level runs a bounded Nelder-Mead search whose simplex starts
|
||||
//! one pixel of that level wide and stops at a tenth of one, so the coarse levels only hand the
|
||||
//! next one a start within its pixel and the finest level searched settles sub-pixel. The pose
|
||||
//! is resolution independent (shifts as fractions of the size, a scale, an angle), so a fit that
|
||||
//! stops a few levels short of full resolution still applies at full resolution, and the cost
|
||||
//! at full resolution, the dominant expense, is never paid.
|
||||
//!
|
||||
//! [`Pose`] is the transform searched: a similarity (shift, scale, rotation) about the layer's
|
||||
//! centre, with aspect and shear for an affine fit and two perspective terms for a projective
|
||||
//! one; [`Motion`] says which of them move. Everything is deterministic.
|
||||
|
||||
use crate::panorama::Motion;
|
||||
use crate::photo_util::{par_map, par_rows2};
|
||||
use crate::transform::Homography;
|
||||
|
||||
/// A layer's transform onto the reference, about the layer's centre: a similarity (shift, scale,
|
||||
/// rotation); with `aspect` and `shear` an affine transform; with `px` and `py` a projective one.
|
||||
/// The reference sits at [`Pose::IDENTITY`]; [`Pose::matrix`] gives it as a 3×3 homography.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Pose {
|
||||
/// Shift, as a fraction of the width.
|
||||
pub dx: f64,
|
||||
/// Shift, as a fraction of the height.
|
||||
pub dy: f64,
|
||||
pub scale: f64,
|
||||
/// Rotation in radians.
|
||||
pub rot: f64,
|
||||
/// Vertical over horizontal scale (1 = isotropic).
|
||||
pub aspect: f64,
|
||||
/// Horizontal shear per unit of height (0 = none).
|
||||
pub shear: f64,
|
||||
/// Perspective: the divisor `1 + px·u/w + py·v/h` over the centred pixel `(u, v)`.
|
||||
pub px: f64,
|
||||
pub py: f64,
|
||||
}
|
||||
|
||||
impl Pose {
|
||||
/// The number of parameters (the length of [`Pose::to_vec`]).
|
||||
pub const N: usize = 8;
|
||||
pub const IDENTITY: Pose = Pose { dx: 0.0, dy: 0.0, scale: 1.0, rot: 0.0, aspect: 1.0, shear: 0.0, px: 0.0, py: 0.0 };
|
||||
/// The search box's half-width around the start, per parameter: 10 % of the layer in shift,
|
||||
/// 10 % in scale, 5° in rotation, 5 % in aspect and shear, 5 % in each perspective term.
|
||||
const SPAN: [f64; Pose::N] = [0.10, 0.10, 0.10, 0.087_266_462_599_716_47, 0.05, 0.05, 0.05, 0.05];
|
||||
|
||||
pub fn to_vec(self) -> [f64; Pose::N] {
|
||||
[self.dx, self.dy, self.scale, self.rot, self.aspect, self.shear, self.px, self.py]
|
||||
}
|
||||
|
||||
pub fn from_vec(v: &[f64; Pose::N]) -> Pose {
|
||||
Pose { dx: v[0], dy: v[1], scale: v[2], rot: v[3], aspect: v[4], shear: v[5], px: v[6], py: v[7] }
|
||||
}
|
||||
|
||||
/// Which parameters a search over `motion` may move: shifts, then scale and rotation, then
|
||||
/// aspect and shear, then the perspective terms.
|
||||
pub fn free(motion: Motion) -> [bool; Pose::N] {
|
||||
match motion {
|
||||
Motion::Translation => [true, true, false, false, false, false, false, false],
|
||||
Motion::Euclidean => [true, true, false, true, false, false, false, false],
|
||||
Motion::Similarity => [true, true, true, true, false, false, false, false],
|
||||
Motion::Homography => [true; Pose::N],
|
||||
}
|
||||
}
|
||||
|
||||
/// The homography mapping the layer's pixels onto the reference's: the linear part is
|
||||
/// `scale · R(rot) · [[1, shear], [0, aspect]]` about the centre, the perspective terms act on
|
||||
/// the centred pixel; without them the last row is exactly `[0, 0, 1]`.
|
||||
pub fn matrix(&self, w: usize, h: usize) -> Homography {
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let (c, s) = (self.rot.cos(), self.rot.sin());
|
||||
let sc = self.scale;
|
||||
let (a, b, d, e) = (sc * c, sc * (c * self.shear - s * self.aspect), sc * s, sc * (s * self.shear + c * self.aspect));
|
||||
let (gx, gy) = (self.px / w as f64, self.py / h as f64);
|
||||
// T(c) · [[a, b, t], [d, e, t'], [gx, gy, 1]] · T(−c), written out.
|
||||
let (a2, b2, d2, e2) = (a + cx * gx, b + cx * gy, d + cy * gx, e + cy * gy);
|
||||
let tx = cx + self.dx * w as f64 - (a2 * cx + b2 * cy);
|
||||
let ty = cy + self.dy * h as f64 - (d2 * cx + e2 * cy);
|
||||
Homography([a2, b2, tx, d2, e2, ty, gx, gy, 1.0 - gx * cx - gy * cy])
|
||||
}
|
||||
}
|
||||
|
||||
/// A luminance plane with, where the layer does not cover its whole area, its coverage.
|
||||
pub struct Plane<'a> {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub luma: &'a [f32],
|
||||
pub valid: Option<&'a [bool]>,
|
||||
}
|
||||
|
||||
/// The outcome of [`register`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Registration {
|
||||
pub pose: Pose,
|
||||
/// The pose as a homography of the target's pixels onto the reference's.
|
||||
pub h: Homography,
|
||||
/// The cost at the pose: the RMS of the mean-removed difference, at the finest level searched.
|
||||
pub rms: f64,
|
||||
/// Pixels of the finest level searched that both layers cover at the pose.
|
||||
pub overlap: usize,
|
||||
/// The correlation of the reference with the warped target over those pixels (Pearson, −1
|
||||
/// to 1): how much of the reference the pose explains, whatever the exposure. Near 1 for a
|
||||
/// bracket of one scene, near 0 for unrelated pictures; 0 without overlap or texture.
|
||||
pub correlation: f64,
|
||||
}
|
||||
|
||||
/// One pyramid level: the plane, its coverage and its size.
|
||||
struct Lvl {
|
||||
luma: Vec<f32>,
|
||||
valid: Option<Vec<bool>>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
const K: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
|
||||
|
||||
/// Gaussian REDUCE of a plane and its coverage (5-tap binomial, clamped borders, phase-0
|
||||
/// subsample); a reduced pixel is covered when more than half its support is.
|
||||
fn reduce(l: &Lvl) -> Lvl {
|
||||
let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2));
|
||||
let cover: Vec<f32> = l.valid.as_ref().map(|v| v.iter().map(|b| *b as u8 as f32).collect()).unwrap_or_default();
|
||||
let pass = |src: &[f32]| -> Vec<f32> {
|
||||
let mut tmp = vec![0.0f32; w * l.h];
|
||||
for (y, row) in tmp.chunks_mut(w).enumerate() {
|
||||
let s = &src[y * l.w..(y + 1) * l.w];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = K.iter().enumerate().map(|(k, wt)| wt * s[(2 * x + k).saturating_sub(2).min(l.w - 1)]).sum();
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
for (y, row) in out.chunks_mut(w).enumerate() {
|
||||
for (k, wt) in K.iter().enumerate() {
|
||||
let sy = (2 * y + k).saturating_sub(2).min(l.h - 1);
|
||||
for (o, v) in row.iter_mut().zip(&tmp[sy * w..(sy + 1) * w]) {
|
||||
*o += wt * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let luma = pass(&l.luma);
|
||||
let valid = (!cover.is_empty()).then(|| pass(&cover).iter().map(|c| *c > 0.5).collect());
|
||||
Lvl { luma, valid, w, h }
|
||||
}
|
||||
|
||||
/// The search pyramid of a plane: level 0 is the plane, each next level half its size, while
|
||||
/// the height exceeds 64 and the width 8.
|
||||
fn levels(p: &Plane) -> Vec<Lvl> {
|
||||
let mut out = vec![Lvl { luma: p.luma.to_vec(), valid: p.valid.map(<[bool]>::to_vec), w: p.w, h: p.h }];
|
||||
while let Some(last) = out.last() {
|
||||
if last.h <= 64 || last.w <= 8 {
|
||||
break;
|
||||
}
|
||||
let next = reduce(last);
|
||||
out.push(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The cubic B-spline (4 taps) at fraction `t`.
|
||||
#[inline]
|
||||
fn spline4(t: f64) -> [f64; 4] {
|
||||
[
|
||||
((-1.0 / 3.0 * t + 0.8) * t - 0.466_666_67) * t,
|
||||
((t - 1.8) * t - 0.2) * t + 1.0,
|
||||
((1.2 - t) * t + 0.8) * t,
|
||||
((1.0 / 3.0 * t - 0.2) * t - 0.133_333_34) * t,
|
||||
]
|
||||
}
|
||||
|
||||
/// The source points along output row `y` under `inv`: `(x0, y0, dx, dy)` such that pixel `x`
|
||||
/// reads `(x0 + dx·x, y0 + dy·x)`; `None` for a projective `inv`, handled pixel by pixel.
|
||||
#[inline]
|
||||
fn affine_row(inv: &Homography, y: f64) -> Option<(f64, f64, f64, f64)> {
|
||||
let m = &inv.0;
|
||||
(m[6] == 0.0 && m[7] == 0.0 && m[8] == 1.0).then(|| (m[1] * y + m[2], m[4] * y + m[5], m[0], m[3]))
|
||||
}
|
||||
|
||||
/// `src` sampled at `(sx, sy)` with the cubic B-spline, edge-clamped.
|
||||
#[inline]
|
||||
fn sample(src: &[f32], w: usize, h: usize, sx: f64, sy: f64) -> f32 {
|
||||
let (x0, y0) = (sx.floor(), sy.floor());
|
||||
let wx = spline4(sx - x0);
|
||||
let wy = spline4(sy - y0);
|
||||
let (x0, y0) = (x0 as i64, y0 as i64);
|
||||
let mut acc = 0.0f64;
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
let yy = (y0 + j as i64 - 1).clamp(0, h as i64 - 1) as usize;
|
||||
let mut r = 0.0f64;
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let xx = (x0 + i as i64 - 1).clamp(0, w as i64 - 1) as usize;
|
||||
r += wxi * f64::from(src[yy * w + xx]);
|
||||
}
|
||||
acc += wyj * r;
|
||||
}
|
||||
acc as f32
|
||||
}
|
||||
|
||||
/// Whether the target covers the source point (nearest pixel of its coverage).
|
||||
#[inline]
|
||||
fn covered(valid: Option<&[bool]>, w: usize, sx: f64, sy: f64) -> bool {
|
||||
valid.is_none_or(|v| v[(sy + 0.5) as usize * w + (sx + 0.5) as usize])
|
||||
}
|
||||
|
||||
/// The cost of `pose`: the RMS of `reference − target ∘ pose`, the mean removed, over the pixels
|
||||
/// of the reference that both cover; 1e9 when fewer than 16 do. One row-parallel pass folds Σr,
|
||||
/// Σt, Σr², Σt², Σrt and the count: rms = √((Σd² − (Σd)²/n) / n) with d = r − t, and the
|
||||
/// correlation of r and t over the same pixels, also returned (0 when either has no spread).
|
||||
fn cost(reference: &Lvl, target: &Lvl, pose: &Pose) -> (f64, usize, f64) {
|
||||
let Some(inv) = pose.matrix(target.w, target.h).inverse() else { return (1e9, 0, 0.0) };
|
||||
let (xmax, ymax) = ((target.w - 1) as f64, (target.h - 1) as f64);
|
||||
let rows = par_map(reference.h, |y| {
|
||||
let (mut sr, mut st, mut sr2, mut st2, mut srt, mut cnt) = (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0usize);
|
||||
let yf = y as f64;
|
||||
let aff = affine_row(&inv, yf);
|
||||
let row = &reference.luma[y * reference.w..(y + 1) * reference.w];
|
||||
for (x, &r) in row.iter().enumerate() {
|
||||
if reference.valid.as_ref().is_some_and(|v| !v[y * reference.w + x]) {
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = match aff {
|
||||
Some((x0, y0, dx, dy)) => (x0 + dx * x as f64, y0 + dy * x as f64),
|
||||
None => inv.apply(x as f64, yf),
|
||||
};
|
||||
let inside = sx >= 0.0 && sx <= xmax && sy >= 0.0 && sy <= ymax;
|
||||
if !inside || !covered(target.valid.as_deref(), target.w, sx, sy) {
|
||||
continue;
|
||||
}
|
||||
let (r, t) = (f64::from(r), f64::from(sample(&target.luma, target.w, target.h, sx, sy)));
|
||||
sr += r;
|
||||
st += t;
|
||||
sr2 += r * r;
|
||||
st2 += t * t;
|
||||
srt += r * t;
|
||||
cnt += 1;
|
||||
}
|
||||
(sr, st, sr2, st2, srt, cnt)
|
||||
});
|
||||
let (sr, st, sr2, st2, srt, cnt) =
|
||||
rows.iter().fold((0.0, 0.0, 0.0, 0.0, 0.0, 0), |a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3, a.4 + b.4, a.5 + b.5));
|
||||
if cnt < 16 {
|
||||
return (1e9, cnt, 0.0);
|
||||
}
|
||||
let n = cnt as f64;
|
||||
// Central moments: Σ(r−r̄)², Σ(t−t̄)², Σ(r−r̄)(t−t̄).
|
||||
let (vr, vt, cov) = ((sr2 - sr * sr / n).max(0.0), (st2 - st * st / n).max(0.0), srt - sr * st / n);
|
||||
let rms = ((vr + vt - 2.0 * cov) / n).max(0.0).sqrt();
|
||||
let correlation = if vr > 0.0 && vt > 0.0 { (cov / (vr * vt).sqrt()).clamp(-1.0, 1.0) } else { 0.0 };
|
||||
(rms, cnt, correlation)
|
||||
}
|
||||
|
||||
/// Full-resolution pixels a point at the layer's edge moves per unit of each parameter (the
|
||||
/// worst case over the layer): the width and height for the shifts, the half-layer for scale
|
||||
/// and rotation, the half-height for aspect and shear, a quarter-layer for the perspective terms.
|
||||
fn param_gain(w: usize, h: usize) -> [f64; Pose::N] {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
let r = w.max(h) / 2.0;
|
||||
[w, h, r, r, h / 2.0, h / 2.0, w / 4.0, h / 4.0]
|
||||
}
|
||||
|
||||
/// The simplex's first step at a level: one pixel of that level.
|
||||
const STEP_PX: f64 = 1.0;
|
||||
/// The simplex's stopping size at a level: a tenth of a pixel of that level.
|
||||
const TOL_PX: f64 = 0.1;
|
||||
|
||||
/// The search's schedule at pyramid level `lvl` (a pixel there is `2^lvl` full-resolution
|
||||
/// pixels) over the parameters `free`: the first step and the stopping size of the simplex, per
|
||||
/// parameter, in the parameter's units.
|
||||
fn level_steps(free: &[usize], lvl: usize, w: usize, h: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let gain = param_gain(w, h);
|
||||
let px = (1u64 << lvl.min(60)) as f64;
|
||||
(free.iter().map(|&k| STEP_PX * px / gain[k]).collect(), free.iter().map(|&k| TOL_PX * px / gain[k]).collect())
|
||||
}
|
||||
|
||||
/// Nelder-Mead's stopping rule on a simplex sorted by `fv`: the values agree to 1e-4 relative
|
||||
/// and every vertex lies within `tol[k]` of the best along each axis.
|
||||
fn converged(simplex: &[Vec<f64>], fv: &[f64], tol: &[f64]) -> bool {
|
||||
let n = tol.len();
|
||||
let (Some(first), Some(last), Some(best)) = (fv.first(), fv.get(n), simplex.first()) else { return true };
|
||||
if (last - first).abs() > 1e-4 * (1.0 + first.abs()) {
|
||||
return false;
|
||||
}
|
||||
simplex[1..].iter().all(|v| (0..n).all(|k| (v[k] - best[k]).abs() <= tol[k]))
|
||||
}
|
||||
|
||||
/// Bounded Nelder-Mead: minimises `f` over the box `[lo, hi]` from `x0`, the first simplex `x0`
|
||||
/// moved by `step[k]` along each axis, until the vertices agree to 1e-4 relative in `f` and lie
|
||||
/// within `tol[k]` of the best along every axis, or 200 iterations.
|
||||
fn nelder_mead<F: Fn(&[f64]) -> f64>(f: &F, x0: &[f64], lo: &[f64], hi: &[f64], step: &[f64], tol: &[f64]) -> Vec<f64> {
|
||||
let n = x0.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let clamp = |v: &mut [f64]| {
|
||||
for k in 0..n {
|
||||
v[k] = v[k].clamp(lo[k], hi[k]);
|
||||
}
|
||||
};
|
||||
let mut simplex: Vec<Vec<f64>> = vec![x0.to_vec()];
|
||||
for k in 0..n {
|
||||
let mut v = x0.to_vec();
|
||||
v[k] += step[k];
|
||||
clamp(&mut v);
|
||||
simplex.push(v);
|
||||
}
|
||||
let mut fv: Vec<f64> = simplex.iter().map(|v| f(v)).collect();
|
||||
let (alpha, gamma, rho, sigma) = (1.0, 2.0, 0.5, 0.5);
|
||||
for _ in 0..200 {
|
||||
let mut idx: Vec<usize> = (0..=n).collect();
|
||||
idx.sort_by(|&a, &b| fv[a].total_cmp(&fv[b]));
|
||||
simplex = idx.iter().map(|&i| simplex[i].clone()).collect();
|
||||
fv = idx.iter().map(|&i| fv[i]).collect();
|
||||
if converged(&simplex, &fv, tol) {
|
||||
break;
|
||||
}
|
||||
let mut c = vec![0.0; n];
|
||||
for v in &simplex[..n] {
|
||||
for k in 0..n {
|
||||
c[k] += v[k] / n as f64;
|
||||
}
|
||||
}
|
||||
let mut xr: Vec<f64> = (0..n).map(|k| c[k] + alpha * (c[k] - simplex[n][k])).collect();
|
||||
clamp(&mut xr);
|
||||
let fr = f(&xr);
|
||||
if fr < fv[0] {
|
||||
let mut xe: Vec<f64> = (0..n).map(|k| c[k] + gamma * (xr[k] - c[k])).collect();
|
||||
clamp(&mut xe);
|
||||
let fe = f(&xe);
|
||||
if fe < fr {
|
||||
(simplex[n], fv[n]) = (xe, fe);
|
||||
} else {
|
||||
(simplex[n], fv[n]) = (xr, fr);
|
||||
}
|
||||
} else if fr < fv[n - 1] {
|
||||
(simplex[n], fv[n]) = (xr, fr);
|
||||
} else {
|
||||
let mut xc: Vec<f64> = (0..n).map(|k| c[k] + rho * (simplex[n][k] - c[k])).collect();
|
||||
clamp(&mut xc);
|
||||
let fc = f(&xc);
|
||||
if fc < fv[n] {
|
||||
(simplex[n], fv[n]) = (xc, fc);
|
||||
} else {
|
||||
let best = simplex[0].clone();
|
||||
for (v, fvi) in simplex[1..].iter_mut().zip(&mut fv[1..]) {
|
||||
for k in 0..n {
|
||||
v[k] = best[k] + sigma * (v[k] - best[k]);
|
||||
}
|
||||
clamp(v);
|
||||
*fvi = f(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = (0..=n).min_by(|&a, &b| fv[a].total_cmp(&fv[b])).unwrap_or(0);
|
||||
simplex[best].clone()
|
||||
}
|
||||
|
||||
/// Registers `target` to `reference` (planes of the same size) with the parameters `motion`
|
||||
/// moves, from `init` (the previous layer's pose, or the identity), stopping `coarsen` levels
|
||||
/// short of full resolution (at least one level is searched).
|
||||
pub fn register(reference: &Plane, target: &Plane, motion: Motion, init: Pose, coarsen: usize) -> Registration {
|
||||
let (w, h) = (target.w, target.h);
|
||||
let pref = levels(reference);
|
||||
let ptgt = levels(target);
|
||||
let n = pref.len().min(ptgt.len());
|
||||
let free: Vec<usize> = (0..Pose::N).filter(|&k| Pose::free(motion)[k]).collect();
|
||||
let iv = init.to_vec();
|
||||
let mut cur = iv;
|
||||
let lo: Vec<f64> = free.iter().map(|&k| iv[k] - Pose::SPAN[k]).collect();
|
||||
let hi: Vec<f64> = free.iter().map(|&k| iv[k] + Pose::SPAN[k]).collect();
|
||||
let finest = coarsen.min(n.saturating_sub(1));
|
||||
let (mut rms, mut overlap, mut correlation) = (1e9, 0, 0.0);
|
||||
for lvl in (finest..n).rev() {
|
||||
let (rl, tl) = (&pref[lvl], &ptgt[lvl]);
|
||||
let snap = cur;
|
||||
let f = |xf: &[f64]| -> f64 {
|
||||
let mut v = snap;
|
||||
for (k, &idx) in free.iter().enumerate() {
|
||||
v[idx] = xf[k];
|
||||
}
|
||||
cost(rl, tl, &Pose::from_vec(&v)).0
|
||||
};
|
||||
let x0: Vec<f64> = free.iter().map(|&k| cur[k]).collect();
|
||||
let (step, tol) = level_steps(&free, lvl, w, h);
|
||||
let best = nelder_mead(&f, &x0, &lo, &hi, &step, &tol);
|
||||
for (k, &idx) in free.iter().enumerate() {
|
||||
cur[idx] = best[k];
|
||||
}
|
||||
(rms, overlap, correlation) = cost(rl, tl, &Pose::from_vec(&cur));
|
||||
}
|
||||
let pose = Pose::from_vec(&cur);
|
||||
Registration { pose, h: pose.matrix(w, h), rms, overlap, correlation }
|
||||
}
|
||||
|
||||
/// `src` (with its coverage) warped by `pose` into a plane of the same size with the cubic
|
||||
/// B-spline, and the coverage of the result: the points that land inside the source, on pixels
|
||||
/// it covers.
|
||||
pub fn warp(src: &Plane, pose: &Pose) -> (Vec<f32>, Vec<bool>) {
|
||||
let (w, h) = (src.w, src.h);
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
let mut valid = vec![false; w * h];
|
||||
let Some(inv) = pose.matrix(w, h).inverse() else { return (out, valid) };
|
||||
let (xmax, ymax) = ((w - 1) as f64, (h - 1) as f64);
|
||||
par_rows2(&mut out, &mut valid, w, |y, orow, vrow| {
|
||||
let yf = y as f64;
|
||||
let aff = affine_row(&inv, yf);
|
||||
for (x, (o, v)) in orow.iter_mut().zip(vrow.iter_mut()).enumerate() {
|
||||
let (sx, sy) = match aff {
|
||||
Some((x0, y0, dx, dy)) => (x0 + dx * x as f64, y0 + dy * x as f64),
|
||||
None => inv.apply(x as f64, yf),
|
||||
};
|
||||
let inside = sx >= 0.0 && sx <= xmax && sy >= 0.0 && sy <= ymax;
|
||||
*v = inside && covered(src.valid, w, sx, sy);
|
||||
if inside {
|
||||
*o = sample(src.luma, w, h, sx, sy);
|
||||
}
|
||||
}
|
||||
});
|
||||
(out, valid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A smooth random texture: a sum of a few sinusoids, so sub-pixel resampling is faithful.
|
||||
fn texture(w: usize, h: usize, seed: u32) -> Vec<f32> {
|
||||
let s = seed as f32;
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let v =
|
||||
(x * 0.21 + s).sin() * (y * 0.17 - s).cos() + (x * 0.071 + y * 0.093 + s * 0.5).sin() * 0.7 + ((x * 0.33 - y * 0.29) * 1.3 + s).cos() * 0.4;
|
||||
0.5 + 0.2 * v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plane(w: usize, h: usize, luma: &[f32]) -> Plane<'_> {
|
||||
Plane { w, h, luma, valid: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pose_matrix_is_the_identity_at_the_identity_and_shifts_in_pixels() {
|
||||
assert_eq!(Pose::IDENTITY.matrix(640, 480), Homography::IDENTITY);
|
||||
let p = Pose { dx: 0.1, dy: -0.05, ..Pose::IDENTITY };
|
||||
let (x, y) = p.matrix(200, 100).apply(50.0, 20.0);
|
||||
assert!((x - 70.0).abs() < 1e-9 && (y - 15.0).abs() < 1e-9);
|
||||
// Scale and rotation are about the centre.
|
||||
let p = Pose { scale: 1.5, rot: 0.3, ..Pose::IDENTITY };
|
||||
let (x, y) = p.matrix(200, 100).apply(100.0, 50.0);
|
||||
assert!((x - 100.0).abs() < 1e-9 && (y - 50.0).abs() < 1e-9);
|
||||
assert_eq!(Pose::from_vec(&p.to_vec()), p);
|
||||
assert_eq!(Pose::free(Motion::Translation).iter().filter(|f| **f).count(), 2);
|
||||
assert_eq!(Pose::free(Motion::Homography), [true; Pose::N]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_by_the_identity_is_exact_and_marks_coverage() {
|
||||
let (w, h) = (40, 30);
|
||||
let src = texture(w, h, 1);
|
||||
let (o, v) = warp(&plane(w, h, &src), &Pose::IDENTITY);
|
||||
assert!(o.iter().zip(&src).all(|(a, b)| (a - b).abs() < 1e-6));
|
||||
assert!(v.iter().all(|b| *b));
|
||||
// A shift of 10 px to the right leaves the left 10 columns uncovered.
|
||||
let (_, v) = warp(&plane(w, h, &src), &Pose { dx: 10.0 / w as f64, ..Pose::IDENTITY });
|
||||
assert!((0..h).all(|y| !v[y * w] && !v[y * w + 9] && v[y * w + 10]));
|
||||
// The source's own coverage carries over.
|
||||
let valid: Vec<bool> = (0..w * h).map(|i| i % w < 20).collect();
|
||||
let (_, v) = warp(&Plane { w, h, luma: &src, valid: Some(&valid) }, &Pose::IDENTITY);
|
||||
assert!(v[5 * w + 10] && !v[5 * w + 30]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_zero_at_the_truth_and_the_sentinel_without_overlap() {
|
||||
let (w, h) = (96, 72);
|
||||
let src = texture(w, h, 2);
|
||||
let l = levels(&plane(w, h, &src));
|
||||
assert!(cost(&l[0], &l[0], &Pose::IDENTITY).0 < 1e-9);
|
||||
assert_eq!(cost(&l[0], &l[0], &Pose { dx: 2.0, ..Pose::IDENTITY }).0, 1e9);
|
||||
// The mean is removed: a brighter copy costs nothing.
|
||||
let bright: Vec<f32> = src.iter().map(|v| v + 0.2).collect();
|
||||
let lb = levels(&plane(w, h, &bright));
|
||||
assert!(cost(&l[0], &lb[0], &Pose::IDENTITY).0 < 1e-6);
|
||||
assert!(cost(&l[0], &lb[0], &Pose { dx: 0.02, ..Pose::IDENTITY }).0 > 1e-3);
|
||||
}
|
||||
|
||||
/// The correlation tells a fit from a non-fit whatever the exposure: a stop brighter copy of
|
||||
/// the texture correlates fully at the truth (where the mean-removed RMS does not vanish),
|
||||
/// another texture hardly at all, and a flat plane not at all.
|
||||
#[test]
|
||||
fn correlation_is_exposure_independent_and_low_for_unrelated_pictures() {
|
||||
let (w, h) = (96, 72);
|
||||
let src = texture(w, h, 2);
|
||||
let l = levels(&plane(w, h, &src));
|
||||
let doubled: Vec<f32> = src.iter().map(|v| v * 2.0).collect();
|
||||
let ld = levels(&plane(w, h, &doubled));
|
||||
let (rms, _, corr) = cost(&l[0], &ld[0], &Pose::IDENTITY);
|
||||
assert!(rms > 0.05 && corr > 0.9999, "rms {rms} correlation {corr}");
|
||||
let other = levels(&plane(w, h, &texture(w, h, 7)));
|
||||
let (_, _, corr) = cost(&l[0], &other[0], &Pose::IDENTITY);
|
||||
assert!(corr.abs() < 0.3, "unrelated textures correlate at {corr}");
|
||||
let flat = levels(&plane(w, h, &vec![0.5; w * h]));
|
||||
assert_eq!(cost(&l[0], &flat[0], &Pose::IDENTITY).2, 0.0);
|
||||
let r = register(&plane(w, h, &src), &plane(w, h, &doubled), Motion::Similarity, Pose::IDENTITY, 0);
|
||||
assert!(r.correlation > 0.999, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyramid_stops_above_64_rows() {
|
||||
let l = levels(&plane(40, 300, &vec![0.25; 40 * 300]));
|
||||
assert_eq!(l.iter().map(|l| (l.w, l.h)).collect::<Vec<_>>(), vec![(40, 300), (20, 150), (10, 75), (5, 38)]);
|
||||
assert!(l.iter().all(|l| l.luma.iter().all(|v| (v - 0.25).abs() < 1e-6)));
|
||||
let valid: Vec<bool> = (0..40 * 300).map(|i| i % 40 < 20).collect();
|
||||
let l = levels(&Plane { w: 40, h: 300, luma: &vec![0.25; 40 * 300], valid: Some(&valid) });
|
||||
let v = l[2].valid.as_ref().unwrap();
|
||||
assert!(v[10 * 10 + 2] && !v[10 * 10 + 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nelder_mead_finds_the_minimum_of_a_bowl() {
|
||||
let f = |x: &[f64]| (x[0] - 0.3).powi(2) + 2.0 * (x[1] + 0.2).powi(2);
|
||||
let r = nelder_mead(&f, &[0.0, 0.0], &[-1.0, -1.0], &[1.0, 1.0], &[0.1, 0.1], &[1e-6, 1e-6]);
|
||||
assert!((r[0] - 0.3).abs() < 1e-4 && (r[1] + 0.2).abs() < 1e-4, "{r:?}");
|
||||
// The box binds.
|
||||
let r = nelder_mead(&f, &[0.0, 0.0], &[-1.0, -1.0], &[0.1, 1.0], &[0.1, 0.1], &[1e-6, 1e-6]);
|
||||
assert!((r[0] - 0.1).abs() < 1e-6, "{r:?}");
|
||||
assert!(nelder_mead(&f, &[], &[], &[], &[], &[]).is_empty());
|
||||
let (step, tol) = level_steps(&[0, 2], 3, 8000, 6000);
|
||||
assert!((step[0] * 8000.0 - 8.0).abs() < 1e-9 && (tol[1] * 4000.0 - 0.8).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A layer shifted, scaled and rotated a little registers back to within a tenth of a pixel
|
||||
/// at every motion model that contains the truth, with or without coarsening.
|
||||
#[test]
|
||||
fn registers_a_similarity_to_a_tenth_of_a_pixel() {
|
||||
let (w, h) = (320, 240);
|
||||
let reference = texture(w, h, 3);
|
||||
let truth = Pose { dx: 3.3 / w as f64, dy: -2.1 / h as f64, scale: 1.012, rot: 0.6f64.to_radians(), ..Pose::IDENTITY };
|
||||
// The pose maps the target's pixels onto the reference's: target(u) = reference(M u).
|
||||
let m = truth.matrix(w, h);
|
||||
let target: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = m.apply((i % w) as f64, (i / w) as f64);
|
||||
sample(&reference, w, h, x.clamp(0.0, (w - 1) as f64), y.clamp(0.0, (h - 1) as f64))
|
||||
})
|
||||
.collect();
|
||||
for (motion, coarsen) in [(Motion::Similarity, 0), (Motion::Similarity, 1), (Motion::Homography, 0)] {
|
||||
let r = register(&plane(w, h, &reference), &plane(w, h, &target), motion, Pose::IDENTITY, coarsen);
|
||||
let (tx, ty) = truth.matrix(w, h).apply(0.0, 0.0);
|
||||
let (rx, ry) = r.h.apply(0.0, 0.0);
|
||||
let corner = ((tx - rx).hypot(ty - ry)).max({
|
||||
let (tx, ty) = truth.matrix(w, h).apply(w as f64, h as f64);
|
||||
let (rx, ry) = r.h.apply(w as f64, h as f64);
|
||||
(tx - rx).hypot(ty - ry)
|
||||
});
|
||||
assert!(corner < 0.1 * (1 << coarsen) as f64 + 0.05, "{motion:?} coarsen {coarsen}: corners off by {corner} px, {:?}", r.pose);
|
||||
let searched = (w >> coarsen) * (h >> coarsen);
|
||||
assert!(r.rms < 0.01 && r.overlap > searched / 2, "{motion:?}: rms {} overlap {} of {searched}", r.rms, r.overlap);
|
||||
}
|
||||
// Translation only: the shift is found, the rest stays put.
|
||||
let t = Pose { dx: 4.0 / w as f64, dy: 3.0 / h as f64, ..Pose::IDENTITY };
|
||||
let target: Vec<f32> = (0..w * h)
|
||||
.map(|i| sample(&reference, w, h, ((i % w) as f64 + 4.0).clamp(0.0, (w - 1) as f64), ((i / w) as f64 + 3.0).clamp(0.0, (h - 1) as f64)))
|
||||
.collect();
|
||||
let r = register(&plane(w, h, &reference), &plane(w, h, &target), Motion::Translation, Pose::IDENTITY, 0);
|
||||
assert!(((r.pose.dx - t.dx) * w as f64).abs() < 0.1 && ((r.pose.dy - t.dy) * h as f64).abs() < 0.1, "{:?}", r.pose);
|
||||
assert_eq!((r.pose.scale, r.pose.rot), (1.0, 0.0));
|
||||
}
|
||||
|
||||
/// Only the covered part counts: a target whose right half is garbage still registers on
|
||||
/// its left half when its coverage says so.
|
||||
#[test]
|
||||
fn coverage_keeps_uncovered_pixels_out_of_the_fit() {
|
||||
let (w, h) = (320, 240);
|
||||
let reference = texture(w, h, 4);
|
||||
let mut target: Vec<f32> = (0..w * h).map(|i| sample(&reference, w, h, ((i % w) as f64 + 2.5).clamp(0.0, (w - 1) as f64), (i / w) as f64)).collect();
|
||||
let valid: Vec<bool> = (0..w * h).map(|i| i % w < w / 2).collect();
|
||||
for (i, v) in target.iter_mut().enumerate() {
|
||||
if !valid[i] {
|
||||
*v = ((i * 7919) % 1000) as f32 / 1000.0;
|
||||
}
|
||||
}
|
||||
let r = register(&plane(w, h, &reference), &Plane { w, h, luma: &target, valid: Some(&valid) }, Motion::Translation, Pose::IDENTITY, 0);
|
||||
assert!((r.pose.dx * w as f64 - 2.5).abs() < 0.1, "{:?}", r.pose);
|
||||
assert!(r.overlap < w * h / 2 + w && r.overlap > w * h / 3);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ pub struct CameraInfo {
|
||||
pub focal_length: Option<f64>,
|
||||
/// 35 mm-equivalent focal length in millimetres.
|
||||
pub focal_length_35mm: Option<f64>,
|
||||
/// DateTimeOriginal as stored (`YYYY:MM:DD HH:MM:SS`).
|
||||
pub date_taken: Option<String>,
|
||||
}
|
||||
|
||||
impl CameraInfo {
|
||||
@@ -35,11 +37,11 @@ struct Tiff<'a> {
|
||||
|
||||
impl Tiff<'_> {
|
||||
fn u16(&self, o: usize) -> Option<u16> {
|
||||
let s = self.b.get(o..o + 2)?;
|
||||
let s = self.b.get(o..o.checked_add(2)?)?;
|
||||
Some(if self.le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
}
|
||||
fn u32(&self, o: usize) -> Option<u32> {
|
||||
let s = self.b.get(o..o + 4)?;
|
||||
let s = self.b.get(o..o.checked_add(4)?)?;
|
||||
let a = [s[0], s[1], s[2], s[3]];
|
||||
Some(if self.le { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) })
|
||||
}
|
||||
@@ -48,20 +50,25 @@ impl Tiff<'_> {
|
||||
let Some(n) = self.u16(off) else { return Vec::new() };
|
||||
(0..n as usize)
|
||||
.filter_map(|k| {
|
||||
let e = off + 2 + k * 12;
|
||||
Some((self.u16(e)?, self.u16(e + 2)?, self.u32(e + 4)?, e + 8))
|
||||
// Checked: offsets come from the file, and usize is 32 bits on wasm.
|
||||
let e = off.checked_add(2)?.checked_add(k * 12)?;
|
||||
Some((self.u16(e)?, self.u16(e.checked_add(2)?)?, self.u32(e.checked_add(4)?)?, e.checked_add(8)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn data_at(&self, typ: u16, count: u32, field: usize) -> Option<usize> {
|
||||
let size = match typ {
|
||||
1 | 2 | 6 | 7 => 1,
|
||||
1 | 2 | 6 | 7 => 1usize,
|
||||
3 | 8 => 2,
|
||||
4 | 9 | 11 => 4,
|
||||
5 | 10 | 12 => 8,
|
||||
_ => return None,
|
||||
} * count as usize;
|
||||
if size <= 4 { Some(field) } else { self.u32(field).map(|v| v as usize) }
|
||||
}
|
||||
.checked_mul(usize::try_from(count).ok()?)?;
|
||||
let at = if size <= 4 { field } else { usize::try_from(self.u32(field)?).ok()? };
|
||||
// The whole value must lie inside the payload, not just its first element.
|
||||
self.b.get(at..at.checked_add(size)?)?;
|
||||
Some(at)
|
||||
}
|
||||
fn number(&self, typ: u16, count: u32, field: usize) -> Option<f64> {
|
||||
let at = self.data_at(typ, count, field)?;
|
||||
@@ -69,11 +76,11 @@ impl Tiff<'_> {
|
||||
3 => self.u16(at).map(f64::from),
|
||||
4 => self.u32(at).map(f64::from),
|
||||
5 => {
|
||||
let (n, d) = (self.u32(at)?, self.u32(at + 4)?);
|
||||
let (n, d) = (self.u32(at)?, self.u32(at.checked_add(4)?)?);
|
||||
(d != 0).then(|| n as f64 / d as f64)
|
||||
}
|
||||
10 => {
|
||||
let (n, d) = (self.u32(at)? as i32, self.u32(at + 4)? as i32);
|
||||
let (n, d) = (self.u32(at)? as i32, self.u32(at.checked_add(4)?)? as i32);
|
||||
(d != 0).then(|| n as f64 / d as f64)
|
||||
}
|
||||
_ => None,
|
||||
@@ -84,7 +91,7 @@ impl Tiff<'_> {
|
||||
return None;
|
||||
}
|
||||
let at = self.data_at(typ, count, field)?;
|
||||
let s = self.b.get(at..at + count as usize)?;
|
||||
let s = self.b.get(at..at.checked_add(usize::try_from(count).ok()?)?)?;
|
||||
let s = String::from_utf8_lossy(s).trim_end_matches('\0').trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
@@ -118,6 +125,7 @@ pub fn read(exif: &[u8]) -> CameraInfo {
|
||||
0x920A => info.focal_length = t.number(typ, count, field),
|
||||
0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0),
|
||||
0xA434 => info.lens = t.text(typ, count, field),
|
||||
0x9003 => info.date_taken = t.text(typ, count, field),
|
||||
0x8769 => {
|
||||
if let Some(p) = t.u32(field) {
|
||||
queue.push(p as usize);
|
||||
@@ -161,6 +169,9 @@ pub fn build(info: &CameraInfo) -> Vec<u8> {
|
||||
if let Some(v) = info.iso {
|
||||
exif.push((0x8827, 3, 1, (v as u16).to_le_bytes().to_vec()));
|
||||
}
|
||||
if let Some(s) = &info.date_taken {
|
||||
exif.push((0x9003, 2, s.len() as u32 + 1, text(s)));
|
||||
}
|
||||
if let Some(v) = info.focal_length {
|
||||
exif.push((0x920A, 5, 1, rational(v)));
|
||||
}
|
||||
@@ -224,6 +235,7 @@ mod tests {
|
||||
iso: Some(200.0),
|
||||
focal_length: Some(35.0),
|
||||
focal_length_35mm: Some(52.0),
|
||||
date_taken: Some("2024:02:17 12:40:19".into()),
|
||||
};
|
||||
let b = build(&info);
|
||||
let r = read(&b);
|
||||
@@ -237,4 +249,53 @@ mod tests {
|
||||
let _ = read(&b[..cut]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random
|
||||
/// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm).
|
||||
#[test]
|
||||
fn hostile_payloads_never_panic() {
|
||||
let entry = |tag: u16, typ: u16, count: u32, value: u32| {
|
||||
let mut e = tag.to_le_bytes().to_vec();
|
||||
e.extend_from_slice(&typ.to_le_bytes());
|
||||
e.extend_from_slice(&count.to_le_bytes());
|
||||
e.extend_from_slice(&value.to_le_bytes());
|
||||
e
|
||||
};
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend_from_slice(&8u32.to_le_bytes());
|
||||
let entries = [
|
||||
entry(0x010F, 2, u32::MAX, 8), // Make: count far past the end
|
||||
entry(0x0110, 2, 16, u32::MAX - 1), // Model: offset at the top of the range
|
||||
entry(0x829A, 5, u32::MAX, 20), // ExposureTime: count overflows the size
|
||||
entry(0x829D, 5, 1, u32::MAX - 3), // FNumber: rational straddles u32::MAX
|
||||
entry(0x9003, 2, 0x4000_0000, 8), // DateTimeOriginal: size overflow on 32 bits
|
||||
entry(0x8769, 4, 1, 8), // Exif IFD pointing back at IFD0
|
||||
entry(0x8769, 4, 1, u32::MAX), // ... and past the end
|
||||
];
|
||||
b.extend_from_slice(&(entries.len() as u16 + 3).to_le_bytes()); // claims more entries than exist
|
||||
for e in entries {
|
||||
b.extend_from_slice(&e);
|
||||
}
|
||||
assert_eq!(read(&b), CameraInfo::default());
|
||||
let mut be = b"MM\0*".to_vec();
|
||||
be.extend_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(read(&be), CameraInfo::default());
|
||||
// Deterministic noise behind a valid header, and every truncation of a real payload.
|
||||
let mut x = 0x2545_F491u32;
|
||||
for _ in 0..200 {
|
||||
let mut n = b"II*\0\x08\0\0\0".to_vec();
|
||||
for _ in 0..96 {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
n.push(x as u8);
|
||||
}
|
||||
let _ = read(&n);
|
||||
}
|
||||
let good =
|
||||
build(&CameraInfo { make: Some("Make".into()), date_taken: Some("2024:02:17 12:40:19".into()), exposure_time: Some(0.004), ..Default::default() });
|
||||
for cut in 0..good.len() {
|
||||
let _ = read(&good[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
mod spin_plan;
|
||||
|
||||
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, gauss_box_radii, premul_window, smoothstep, unpremul_px, xy};
|
||||
use crate::image::{Image, premultiply};
|
||||
use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
|
||||
@@ -16,6 +18,10 @@ use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
|
||||
#[path = "tests_iris.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_tilt_shift.rs"]
|
||||
mod tilt_shift_tests;
|
||||
|
||||
/// Gaussian σ for a gallery blur amount in px.
|
||||
fn sigma_of(blur: f32) -> f32 {
|
||||
blur.clamp(0.0, 500.0) * 0.6
|
||||
@@ -86,7 +92,7 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool
|
||||
let (level, lw): (&[f32], usize) = if k == 0 {
|
||||
(&p, ww)
|
||||
} else {
|
||||
// Only Iris opts in. Each level needs its own box-cascade reach,
|
||||
// Iris and Tilt-Shift opt in. Each level needs its own box-cascade reach,
|
||||
// not the maximum blur's trailing halo. Keep the original top/left:
|
||||
// moving the running sums' start changes floating-point cancellation.
|
||||
let trim = trim_levels && win.intersect(&out) == out;
|
||||
@@ -146,9 +152,10 @@ pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: (
|
||||
// Band normal (the band runs along `angle`).
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
let (nx, ny) = (s, c);
|
||||
let smax = sigma_of(blur);
|
||||
// A NaN amount must not become the upper bound of f32::clamp below.
|
||||
let smax = sigma_of(blur).max(0.0);
|
||||
let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3));
|
||||
variable_blur(src, out, ctx, smax, false, |x, y| {
|
||||
variable_blur(src, out, ctx, smax, true, |x, y| {
|
||||
let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss;
|
||||
smax * smoothstep(f0, f1, d)
|
||||
})
|
||||
@@ -264,7 +271,18 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
|
||||
/// Spin Blur: content inside each ellipse is blurred along arcs about its
|
||||
/// centre (`blur_angle` degrees of rotation), feathered at the rim.
|
||||
pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
match src.ch {
|
||||
1 => spin_impl::<1>(src, out, ctx, pins, true),
|
||||
2 => spin_impl::<2>(src, out, ctx, pins, true),
|
||||
3 => spin_impl::<3>(src, out, ctx, pins, true),
|
||||
4 => spin_impl::<4>(src, out, ctx, pins, true),
|
||||
5 => spin_impl::<5>(src, out, ctx, pins, true),
|
||||
_ => spin_impl::<0>(src, out, ctx, pins, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn spin_impl<const N: usize>(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin], prepare: bool) -> Vec<f32> {
|
||||
let n = if N == 0 { src.ch } else { N };
|
||||
let b = ctx.bounds;
|
||||
let ss = short_side(b);
|
||||
let mut res = src.crop(out);
|
||||
@@ -272,11 +290,12 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
|
||||
if clip.is_empty() {
|
||||
return res;
|
||||
}
|
||||
let plans = prepare.then(|| spin_plan::prepare(pins)).flatten();
|
||||
let mut acc = [0.0f32; MAXC];
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
for p in pins {
|
||||
for (pi, p) in pins.iter().enumerate() {
|
||||
let (e, u, v) = pin_distance(fx, fy, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, 2.0);
|
||||
if !e.is_finite() || e >= 1.0 {
|
||||
continue;
|
||||
@@ -288,15 +307,20 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
|
||||
let (cx, cy) = (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32);
|
||||
let angle_deg = if p.angle.is_finite() { p.angle } else { 0.0 };
|
||||
let (sa, ca) = angle_deg.to_radians().sin_cos();
|
||||
let rotations = plans.as_ref().and_then(|plans| plans.get(pi)).and_then(|plan| plan.rotations(m));
|
||||
acc[..n].fill(0.0);
|
||||
for j in 0..m {
|
||||
let phi = theta * (j as f32 / (m - 1) as f32 - 0.5);
|
||||
let (sp, cp) = phi.sin_cos();
|
||||
let (sp, cp) = rotations.and_then(|table| table.get(j)).copied().unwrap_or_else(|| phi.sin_cos());
|
||||
// Rotate in the ellipse's normalized space, then map back.
|
||||
let (ru, rv) = (u * cp - v * sp, u * sp + v * cp);
|
||||
let (lu, lv) = (ru * p.radius_x * ss, rv * p.radius_y * ss);
|
||||
let (sx, sy) = (cx + lu * ca - lv * sa, cy + lu * sa + lv * ca);
|
||||
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
|
||||
if N == 0 {
|
||||
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
|
||||
} else {
|
||||
spin_plan::sample::<N>(src, sx, sy, clip, ctx.alpha, &mut acc);
|
||||
}
|
||||
}
|
||||
for v in acc.iter_mut().take(n) {
|
||||
*v /= m as f32;
|
||||
@@ -376,3 +400,6 @@ pub(crate) fn path(src: &Image, out: Rect, ctx: &Ctx, paths: &[BlurPath]) -> Vec
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_spin_tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
#[test]
|
||||
fn all_rotation_tables_keep_the_direct_bits() {
|
||||
for angle in [0.0, 0.1, 15.0, 73.7, 180.0, 360.0, f32::NAN, f32::INFINITY] {
|
||||
let p = SpinPin { blur_angle: angle, ..Default::default() };
|
||||
let plans = spin_plan::prepare(&[p]).unwrap();
|
||||
let theta = angle.clamp(0.0, 360.0).to_radians();
|
||||
for count in 2..=64 {
|
||||
for j in 0..count {
|
||||
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
|
||||
let (a, b) = phi.sin_cos();
|
||||
let (c, d) = plans[0].rotation(count, j).unwrap();
|
||||
assert_eq!((a.to_bits(), b.to_bits()), (c.to_bits(), d.to_bits()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(spin_plan::prepare(&vec![SpinPin::default(); 121]).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn prepared_spin_matches_the_original_kernel() {
|
||||
let bounds = Rect::new(-17, -11, 58, 45);
|
||||
for mode in [
|
||||
ColorMode::Rgb,
|
||||
ColorMode::Grayscale,
|
||||
ColorMode::Cmyk,
|
||||
ColorMode::Lab,
|
||||
ColorMode::Bitmap,
|
||||
ColorMode::Duotone,
|
||||
ColorMode::Indexed,
|
||||
ColorMode::Multichannel,
|
||||
] {
|
||||
for alpha in [false, true] {
|
||||
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
let mut src = Image::new(bounds, n);
|
||||
for (i, v) in src.data.iter_mut().enumerate() {
|
||||
*v = ((i * 71 + i / n * 107) % 65536) as f32 / 65535.0;
|
||||
}
|
||||
for (i, px) in src.data.chunks_exact_mut(n).enumerate() {
|
||||
if i % 11 == 0 {
|
||||
px[n - 1] = 0.0;
|
||||
}
|
||||
}
|
||||
for angle in [0.0, 0.1, 15.0, 91.0, 360.0, f32::NAN] {
|
||||
let pins = [
|
||||
SpinPin { x: 0.2, y: 0.3, radius_x: 0.25, radius_y: 0.5, angle: 31.0, blur_angle: angle },
|
||||
SpinPin { x: 0.7, y: 0.6, radius_x: 0.8, radius_y: 0.6, angle: -15.0, blur_angle: angle },
|
||||
];
|
||||
let out = Rect::new(-19, -8, 55, 49);
|
||||
let a = spin(&src, out, &ctx, &pins);
|
||||
let b = spin_impl::<0>(&src, out, &ctx, &pins, false);
|
||||
let first = a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits());
|
||||
assert_eq!(first, None, "{mode:?} alpha {alpha} angle {angle}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! All possible sample counts share a bounded rotation table per pin.
|
||||
use crate::SpinPin;
|
||||
|
||||
pub(super) struct Plan {
|
||||
rotations: Vec<(f32, f32)>,
|
||||
}
|
||||
impl Plan {
|
||||
#[cfg(test)]
|
||||
pub(super) fn rotation(&self, count: usize, sample: usize) -> Option<(f32, f32)> {
|
||||
self.rotations(count)?.get(sample).copied()
|
||||
}
|
||||
pub(super) fn rotations(&self, count: usize) -> Option<&[(f32, f32)]> {
|
||||
if !(2..=64).contains(&count) {
|
||||
return None;
|
||||
}
|
||||
let start = count.checked_mul(count - 1)?.checked_div(2)?.checked_sub(1)?;
|
||||
self.rotations.get(start..start.checked_add(count)?)
|
||||
}
|
||||
}
|
||||
pub(super) fn prepare(pins: &[SpinPin]) -> Option<Vec<Plan>> {
|
||||
// Under 2 MiB, even for 120 overlapping pins. Larger lists keep direct sampling.
|
||||
if pins.len() > 120 {
|
||||
return None;
|
||||
}
|
||||
let mut plans = Vec::new();
|
||||
plans.try_reserve_exact(pins.len()).ok()?;
|
||||
for p in pins {
|
||||
let theta = p.blur_angle.clamp(0.0, 360.0).to_radians();
|
||||
let mut rotations = Vec::new();
|
||||
rotations.try_reserve_exact(2079).ok()?;
|
||||
for count in 2..=64 {
|
||||
for j in 0..count {
|
||||
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
|
||||
rotations.push(phi.sin_cos());
|
||||
}
|
||||
}
|
||||
plans.push(Plan { rotations });
|
||||
}
|
||||
Some(plans)
|
||||
}
|
||||
|
||||
/// Fixed channels keep the bilinear accumulators in registers. Tap and multiply order
|
||||
/// stay identical to add_sample_premul; alpha is still multiplied by the tap weight first.
|
||||
pub(super) fn sample<const N: usize>(src: &crate::image::Image, x: f32, y: f32, clip: photocraft_geom::Rect, alpha: bool, acc: &mut [f32; 8]) {
|
||||
if !x.is_finite() || !y.is_finite() || clip.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fx = (x - 0.5).clamp(clip.x0 as f32, (clip.x1 - 1) as f32);
|
||||
let fy = (y - 0.5).clamp(clip.y0 as f32, (clip.y1 - 1) as f32);
|
||||
let (x0, y0) = (fx.floor() as i32, fy.floor() as i32);
|
||||
let (x1, y1) = ((x0 + 1).min(clip.x1 - 1), (y0 + 1).min(clip.y1 - 1));
|
||||
let (ax, ay) = (fx - x0 as f32, fy - y0 as f32);
|
||||
let w = src.rect.width() as usize;
|
||||
let idx = |xx: i32, yy: i32| ((yy - src.rect.y0) as usize * w + (xx - src.rect.x0) as usize) * N;
|
||||
for (i, k) in [(idx(x0, y0), (1.0 - ax) * (1.0 - ay)), (idx(x1, y0), ax * (1.0 - ay)), (idx(x0, y1), (1.0 - ax) * ay), (idx(x1, y1), ax * ay)] {
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let Some(px) = src.data.get(i..i + N).and_then(|s| s.first_chunk::<N>()) else {
|
||||
return;
|
||||
};
|
||||
let a = if alpha { px.get(N - 1).copied().unwrap_or(0.0) * k } else { k };
|
||||
if alpha {
|
||||
for (sum, value) in acc.iter_mut().zip(px).take(N - 1) {
|
||||
*sum += value * a;
|
||||
}
|
||||
if let Some(sum) = acc.get_mut(N - 1) {
|
||||
*sum += a;
|
||||
}
|
||||
} else {
|
||||
for (sum, value) in acc.iter_mut().zip(px).take(N) {
|
||||
*sum += value * a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,10 @@ const TILE: i32 = 128;
|
||||
/// to: samples clamp to it, and pixels mapping outside it take the edge mode. `map` returns where
|
||||
/// each output pixel centre samples (`None` = outside).
|
||||
pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync)) -> Surface {
|
||||
remap_impl(src, frame, out_area, edge, map, true)
|
||||
}
|
||||
|
||||
fn remap_impl(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync), shared: bool) -> Surface {
|
||||
let sfmt = src.format();
|
||||
let needs_alpha = matches!(edge, EdgeMode::Transparency) && !sfmt.alpha;
|
||||
let ofmt = if needs_alpha { PixelFormat::new(sfmt.mode, sfmt.sample, true) } else { sfmt };
|
||||
@@ -363,7 +367,14 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
|
||||
let fy = ((s.p[1] - fy0).min(fy1 - s.p[1]) + 0.5).clamp(0.0, 1.0);
|
||||
(fx * fy) as f32
|
||||
};
|
||||
let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
|
||||
// Color channels and coverage usually sample the same location. Prepare the 16
|
||||
// tap addresses and weights once; each channel keeps its original summation order.
|
||||
let center = if shared && (!rgb || (s.pr == s.p && s.pb == s.p)) { sample_channels(&buf, win, frame, n_in, s.p) } else { None };
|
||||
let sample = |c: usize, p: [f64; 2]| {
|
||||
let cached = if p == s.p { center.as_ref() } else { None };
|
||||
cached.and_then(|v| v.get(c)).copied().unwrap_or_else(|| bicubic(c, p))
|
||||
};
|
||||
let alpha = if sfmt.alpha { sample(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let mut col = [0.0f32; 8];
|
||||
for (c, v) in col.iter_mut().enumerate().take(cc) {
|
||||
let p = if rgb && c == 0 {
|
||||
@@ -373,7 +384,7 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
|
||||
} else {
|
||||
s.p
|
||||
};
|
||||
let pm = bicubic(c, p);
|
||||
let pm = sample(c, p);
|
||||
let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm };
|
||||
let g = s.gain;
|
||||
*v = if subtractive {
|
||||
@@ -418,6 +429,41 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
|
||||
out
|
||||
}
|
||||
|
||||
fn sample_channels(buf: &[f32], win: Rect, frame: Rect, n: usize, p: [f64; 2]) -> Option<[f32; 8]> {
|
||||
match n {
|
||||
1 => sample_channels_impl::<1>(buf, win, frame, p),
|
||||
2 => sample_channels_impl::<2>(buf, win, frame, p),
|
||||
3 => sample_channels_impl::<3>(buf, win, frame, p),
|
||||
4 => sample_channels_impl::<4>(buf, win, frame, p),
|
||||
5 => sample_channels_impl::<5>(buf, win, frame, p),
|
||||
8 => sample_channels_impl::<8>(buf, win, frame, p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn sample_channels_impl<const N: usize>(buf: &[f32], win: Rect, frame: Rect, p: [f64; 2]) -> Option<[f32; 8]> {
|
||||
if N == 0 || N > 8 || win.is_empty() || frame.is_empty() || !p.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let x = (p[0] - 0.5).clamp(frame.x0 as f64, frame.x1 as f64 - 1.0);
|
||||
let y = (p[1] - 0.5).clamp(frame.y0 as f64, frame.y1 as f64 - 1.0);
|
||||
let (xi, yi) = (x.floor(), y.floor());
|
||||
let (wx, wy) = (catmull_rom((x - xi) as f32), catmull_rom((y - yi) as f32));
|
||||
let mut acc = [0.0f32; 8];
|
||||
for (j, wyv) in wy.iter().enumerate() {
|
||||
let yy = ((yi as i32 + j as i32 - 1).clamp(win.y0, win.y1 - 1) - win.y0) as usize;
|
||||
for (i, wxv) in wx.iter().enumerate() {
|
||||
let xx = ((xi as i32 + i as i32 - 1).clamp(win.x0, win.x1 - 1) - win.x0) as usize;
|
||||
let start = yy.checked_mul(win.width() as usize)?.checked_add(xx)?.checked_mul(N)?;
|
||||
let px = buf.get(start..start.checked_add(N)?)?.first_chunk::<N>()?;
|
||||
let weight = wxv * wyv;
|
||||
for (a, v) in acc.iter_mut().zip(px) {
|
||||
*a += weight * *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
/// Applies a lens correction to `src` over `frame` (normally the canvas).
|
||||
pub fn correct(src: &Surface, frame: Rect, lc: &LensCorrection) -> Surface {
|
||||
let map = LensMap::new(lc, frame);
|
||||
@@ -522,3 +568,6 @@ mod tests {
|
||||
assert!(out.pixel(0, 0)[0] < 0.4 && (out.pixel(20, 20)[0] - 0.5).abs() < 0.02, "{:?}", out.pixel(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_sample_tests;
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
#[test]
|
||||
fn shared_interpolation_matches_original_at_all_depths_and_edges() {
|
||||
let bounds = Rect::new(-13, -9, 39, 26);
|
||||
for mode in [
|
||||
ColorMode::Rgb,
|
||||
ColorMode::Grayscale,
|
||||
ColorMode::Cmyk,
|
||||
ColorMode::Lab,
|
||||
ColorMode::Bitmap,
|
||||
ColorMode::Duotone,
|
||||
ColorMode::Indexed,
|
||||
ColorMode::Multichannel,
|
||||
] {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for alpha in [false, true] {
|
||||
let fmt = PixelFormat::new(mode, sample, alpha);
|
||||
let n = fmt.channels();
|
||||
let mut s = Surface::new(fmt);
|
||||
let data: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize * n)
|
||||
.map(|i| if i % 17 == 0 { 0.0 } else { ((i * 103 + i / n * 71) % 65536) as f32 / 65535.0 })
|
||||
.collect();
|
||||
s.write_region(bounds, &data);
|
||||
for edge in [EdgeMode::Transparency, EdgeMode::Extension, EdgeMode::Color([0.2, 0.5, 0.7, 0.4])] {
|
||||
for ca in [false, true] {
|
||||
let map = |x: f64, y: f64| {
|
||||
if (x as i32 + y as i32) % 13 == 0 {
|
||||
return None;
|
||||
}
|
||||
let p = [x * 0.83 - 3.7, y * 1.14 + 1.3];
|
||||
Some(Sample { p, pr: if ca { [p[0] - 0.9, p[1] + 0.3] } else { p }, pb: if ca { [p[0] + 0.7, p[1] - 0.4] } else { p }, gain: 1.17 })
|
||||
};
|
||||
let out = bounds.inflate(3);
|
||||
let a = remap_impl(&s, bounds, out, edge, &map, true).read_region(out);
|
||||
let b = remap_impl(&s, bounds, out, edge, &map, false).read_region(out);
|
||||
assert_eq!(
|
||||
a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits()),
|
||||
None,
|
||||
"{mode:?} {sample:?} alpha {alpha} edge {edge:?} ca {ca}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn shared_sampler_rejects_bad_windows_and_nonfinite_points() {
|
||||
let win = Rect::new(0, 0, 3, 3);
|
||||
assert!(sample_channels(&[], win, win, 4, [1.0, 1.0]).is_none());
|
||||
assert!(sample_channels(&[0.0; 36], win, win, 4, [f64::NAN, 1.0]).is_none());
|
||||
assert!(sample_channels(&[], Rect::EMPTY, win, 4, [1.0, 1.0]).is_none());
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub mod camera_raw;
|
||||
pub mod color_to_alpha;
|
||||
pub mod content_aware;
|
||||
mod denoise;
|
||||
pub mod direct;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
@@ -48,6 +49,7 @@ mod other;
|
||||
mod other2;
|
||||
pub mod paint;
|
||||
pub mod panorama;
|
||||
mod par;
|
||||
pub mod perspective;
|
||||
mod photo_util;
|
||||
mod pixelate;
|
||||
|
||||
@@ -116,7 +116,8 @@ pub struct LiquifyStroke {
|
||||
/// Brush diameter (px).
|
||||
#[serde(default = "d_size")]
|
||||
pub size: f64,
|
||||
/// Edge feathering, 0 (hard) ..100 (soft).
|
||||
/// How far the full effect reaches toward the brush edge, 0 (only the centre; feathered)
|
||||
/// ..100 (most of the brush). It always fades to nothing at the edge ([`falloff`]).
|
||||
#[serde(default = "d_density")]
|
||||
pub density: f64,
|
||||
/// Overall strength, 0..100.
|
||||
@@ -420,8 +421,7 @@ impl LiquifyField {
|
||||
if t2 >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
let soft = (1.0 - t2) * (1.0 - t2);
|
||||
let fall = (1.0 - density) + density * soft;
|
||||
let fall = falloff(t2, density);
|
||||
let idx = j * self.w + i;
|
||||
let k_raw = strength * fall;
|
||||
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
|
||||
@@ -526,6 +526,23 @@ impl LiquifyField {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dab's strength at squared distance `t2` (in brush radii, 0..1) for Density `density` (0..1):
|
||||
/// 1 at the centre, and down to 0 at the rim with a flat slope at every density, as in
|
||||
/// Photoshop, where Density sets how far the full effect reaches toward the edge (0: only the
|
||||
/// centre, the rest feathered; 1: most of the brush) but the effect always fades out at it.
|
||||
/// A brush that stopped short of 0 at its rim moved the pixels inside it and left those just
|
||||
/// outside, tearing the image along every dab's circle (#2606).
|
||||
pub fn falloff(t2: f64, density: f64) -> f64 {
|
||||
let t2 = t2.clamp(0.0, 1.0);
|
||||
let density = if density.is_nan() { 0.5 } else { density.clamp(0.0, 1.0) };
|
||||
// Feathered: (1 - t²)³. Dense: (1 - t⁴)², flat in the middle. Both are 0 with zero slope at
|
||||
// t = 1, and their slopes stay small enough that a dab every fifth of the radius never folds
|
||||
// the image over itself.
|
||||
let feathered = (1.0 - t2).powi(3);
|
||||
let dense = (1.0 - t2 * t2).powi(2);
|
||||
feathered + (dense - feathered) * density
|
||||
}
|
||||
|
||||
fn bilinear(d: &[[f32; 2]], w: usize, h: usize, gx: f64, gy: f64) -> [f64; 2] {
|
||||
if !(gx > -1.0 && gy > -1.0 && gx < w as f64 && gy < h as f64) {
|
||||
return [0.0, 0.0];
|
||||
@@ -680,6 +697,8 @@ pub struct ProxyImage {
|
||||
pub px: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
const MAX_FULL_RES_SOURCE_PIXELS: usize = 8 * 1024 * 1024;
|
||||
|
||||
impl ProxyImage {
|
||||
/// Box-downsamples `src` over `bounds` so the longer side is at most `max_side`.
|
||||
pub fn new(src: &Surface, bounds: Rect, max_side: usize) -> Self {
|
||||
@@ -760,6 +779,130 @@ impl ProxyImage {
|
||||
rows.chunks_mut(self.w).enumerate().for_each(|(i, line)| row(y0 + i, line));
|
||||
}
|
||||
|
||||
/// Renders a document-pixel-resolution preview for `region` into the corresponding part of
|
||||
/// `out`, whose dimensions cover `bounds`. Source reads are limited to the dirty region plus
|
||||
/// the displacement range that can affect it, so zoomed-in brush updates do not flatten the
|
||||
/// whole layer. `source_scratch` is reused by the caller between updates.
|
||||
pub fn render_full_resolution(
|
||||
src: &Surface,
|
||||
field: &LiquifyField,
|
||||
bounds: Rect,
|
||||
region: Rect,
|
||||
out: &mut [[u8; 4]],
|
||||
source_scratch: &mut Vec<[f32; 4]>,
|
||||
) -> bool {
|
||||
let bounds = bounds.intersect(&field.bounds);
|
||||
let region = region.intersect(&bounds);
|
||||
let (width, height) = (bounds.width() as usize, bounds.height() as usize);
|
||||
let Some(out_len) = width.checked_mul(height) else { return false };
|
||||
if region.is_empty() || out.len() != out_len || width == 0 || height == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bilinear field interpolation is a convex combination of its surrounding nodes. The
|
||||
// extrema of those nodes therefore bound every source sample in this output rectangle.
|
||||
let (mut min_dx, mut min_dy, mut max_dx, mut max_dy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
let (gx0, gy0) = field.to_grid(f64::from(region.x0) + 0.5, f64::from(region.y0) + 0.5);
|
||||
let (gx1, gy1) = field.to_grid(f64::from(region.x1) - 0.5, f64::from(region.y1) - 0.5);
|
||||
let (i0, j0) = (gx0.floor().max(0.0) as usize, gy0.floor().max(0.0) as usize);
|
||||
let (i1, j1) = (gx1.ceil().max(0.0) as usize, gy1.ceil().max(0.0) as usize);
|
||||
for j in j0..=j1.min(field.h.saturating_sub(1)) {
|
||||
for i in i0..=i1.min(field.w.saturating_sub(1)) {
|
||||
let Some(v) = j.checked_mul(field.w).and_then(|row| row.checked_add(i)).and_then(|idx| field.d.get(idx)) else { continue };
|
||||
min_dx = min_dx.min(v[0]);
|
||||
min_dy = min_dy.min(v[1]);
|
||||
max_dx = max_dx.max(v[0]);
|
||||
max_dy = max_dy.max(v[1]);
|
||||
}
|
||||
}
|
||||
if !min_dx.is_finite() || !min_dy.is_finite() || !max_dx.is_finite() || !max_dy.is_finite() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Source coordinates are pixel centres. Add one sample on each side for bilinear
|
||||
// interpolation, then clamp to the document just as the regular proxy renderer does.
|
||||
let x0 = (f64::from(region.x0) + f64::from(min_dx)).floor() - 1.0;
|
||||
let y0 = (f64::from(region.y0) + f64::from(min_dy)).floor() - 1.0;
|
||||
let x1 = (f64::from(region.x1 - 1) + f64::from(max_dx)).ceil() + 2.0;
|
||||
let y1 = (f64::from(region.y1 - 1) + f64::from(max_dy)).ceil() + 2.0;
|
||||
let canvas = field.bounds;
|
||||
let src_rect = Rect::new(
|
||||
x0.max(f64::from(canvas.x0)).min(f64::from(canvas.x1)) as i32,
|
||||
y0.max(f64::from(canvas.y0)).min(f64::from(canvas.y1)) as i32,
|
||||
x1.max(f64::from(canvas.x0)).min(f64::from(canvas.x1)) as i32,
|
||||
y1.max(f64::from(canvas.y0)).min(f64::from(canvas.y1)) as i32,
|
||||
);
|
||||
if src_rect.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (sw, sh) = (src_rect.width() as usize, src_rect.height() as usize);
|
||||
let Some(source_len) = sw.checked_mul(sh) else { return false };
|
||||
if source_len > MAX_FULL_RES_SOURCE_PIXELS {
|
||||
return false;
|
||||
}
|
||||
if source_scratch.try_reserve(source_len.saturating_sub(source_scratch.len())).is_err() {
|
||||
return false;
|
||||
}
|
||||
source_scratch.resize(source_len, [0.0; 4]);
|
||||
src.read_rgba_into(src_rect, source_scratch);
|
||||
if !src.format().alpha {
|
||||
for pixel in source_scratch.iter_mut() {
|
||||
pixel[3] = 1.0;
|
||||
}
|
||||
}
|
||||
for pixel in source_scratch.iter_mut() {
|
||||
let alpha = pixel[3];
|
||||
pixel[0] *= alpha;
|
||||
pixel[1] *= alpha;
|
||||
pixel[2] *= alpha;
|
||||
}
|
||||
|
||||
let x0 = (region.x0 - bounds.x0) as usize;
|
||||
let x1 = (region.x1 - bounds.x0) as usize;
|
||||
let y0 = (region.y0 - bounds.y0) as usize;
|
||||
let y1 = (region.y1 - bounds.y0) as usize;
|
||||
let source_at = |x: f64, y: f64| -> [f32; 4] {
|
||||
let fx = (x - 0.5).clamp(f64::from(canvas.x0), f64::from(canvas.x1 - 1));
|
||||
let fy = (y - 0.5).clamp(f64::from(canvas.y0), f64::from(canvas.y1 - 1));
|
||||
let ix = fx.floor();
|
||||
let iy = fy.floor();
|
||||
let (tx, ty) = ((fx - ix) as f32, (fy - iy) as f32);
|
||||
let sx0 = (ix as i32).clamp(src_rect.x0, src_rect.x1 - 1);
|
||||
let sy0 = (iy as i32).clamp(src_rect.y0, src_rect.y1 - 1);
|
||||
let sx1 = (sx0 + 1).min(src_rect.x1 - 1);
|
||||
let sy1 = (sy0 + 1).min(src_rect.y1 - 1);
|
||||
let at = |sx: i32, sy: i32| -> [f32; 4] {
|
||||
let ox = (sx - src_rect.x0) as usize;
|
||||
let oy = (sy - src_rect.y0) as usize;
|
||||
source_scratch.get(oy * sw + ox).copied().unwrap_or([0.0; 4])
|
||||
};
|
||||
let (a, b, c, d) = (at(sx0, sy0), at(sx1, sy0), at(sx0, sy1), at(sx1, sy1));
|
||||
std::array::from_fn(|channel| (a[channel] * (1.0 - tx) + b[channel] * tx) * (1.0 - ty) + (c[channel] * (1.0 - tx) + d[channel] * tx) * ty)
|
||||
};
|
||||
let render_rows = |first_y: usize, rows: &mut [[u8; 4]]| {
|
||||
for (offset, row) in rows.chunks_mut(width).enumerate() {
|
||||
let y = bounds.y0 + (first_y + offset) as i32;
|
||||
let Some(dest) = row.get_mut(x0..x1) else { continue };
|
||||
for (offset_x, pixel) in dest.iter_mut().enumerate() {
|
||||
let x = bounds.x0 + (x0 + offset_x) as i32;
|
||||
let p = [f64::from(x) + 0.5, f64::from(y) + 0.5];
|
||||
let d = field.sample(p[0], p[1]);
|
||||
let rgba = source_at(p[0] + d[0], p[1] + d[1]);
|
||||
*pixel = rgba.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(rows) = out.get_mut(y0 * width..y1 * width) else { return false };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if (x1 - x0).saturating_mul(y1 - y0) > 150_000 {
|
||||
use rayon::prelude::*;
|
||||
rows.par_chunks_mut(width).enumerate().for_each(|(offset, line)| render_rows(y0 + offset, line));
|
||||
return true;
|
||||
}
|
||||
rows.chunks_mut(width).enumerate().for_each(|(offset, line)| render_rows(y0 + offset, line));
|
||||
true
|
||||
}
|
||||
|
||||
/// The proxy pixel rectangle covering document rect `r`.
|
||||
pub fn proxy_rect(&self, r: Rect) -> [usize; 4] {
|
||||
let s = self.scale;
|
||||
@@ -940,6 +1083,57 @@ mod tests {
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_brush_fades_to_nothing_at_its_rim_at_every_density() {
|
||||
for density in [0.0, 0.25, 0.5, 0.75, 1.0, f64::NAN, -3.0, 7.0] {
|
||||
assert_eq!(falloff(0.0, density), 1.0, "full strength at the centre ({density})");
|
||||
assert_eq!(falloff(1.0, density), 0.0, "nothing at the rim ({density})");
|
||||
// Flat at the rim: the last 2 % of the radius moves at most a couple of percent.
|
||||
assert!(falloff(0.98f64.powi(2), density) < 0.02, "{density}: {}", falloff(0.98f64.powi(2), density));
|
||||
let mut prev = 1.0;
|
||||
for k in 1..=100 {
|
||||
let v = falloff(k as f64 / 100.0, density);
|
||||
assert!(v <= prev && v >= 0.0, "{density}: not falling at t² = {k}%");
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
// Density widens the strong core: more strength at every radius, as in Photoshop.
|
||||
for k in 1..100 {
|
||||
let t2 = k as f64 / 100.0;
|
||||
assert!(falloff(t2, 1.0) >= falloff(t2, 0.5) && falloff(t2, 0.5) >= falloff(t2, 0.0), "t² = {t2}");
|
||||
}
|
||||
assert!(falloff(0.5, 1.0) > 0.5 && falloff(0.5, 0.0) < 0.2);
|
||||
}
|
||||
|
||||
/// #2606: the dab's strength used to stop at 1 - Density at its rim, so a Forward Warp moved
|
||||
/// everything just inside the brush and nothing just outside, tearing the image along every
|
||||
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
|
||||
#[test]
|
||||
fn forward_warp_leaves_the_brush_rim_in_place() {
|
||||
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 4.0);
|
||||
for density in [0.0, 50.0, 100.0] {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
|
||||
s.density = density;
|
||||
s.points = vec![vec![48.0, 32.0], vec![48.0 + drag, 32.0]];
|
||||
let f = LiquifyField::from_strokes(bounds(), 1.0, &[s]);
|
||||
let mut rim = 0;
|
||||
for j in 0..f.h {
|
||||
for i in 0..f.w {
|
||||
let p = f.node_pos(i, j);
|
||||
let t = (p[0] - 48.0).hypot(p[1] - 32.0) / r;
|
||||
if (0.97..1.0).contains(&t) {
|
||||
let d = f.d[j * f.w + i];
|
||||
assert!(f64::from(d[0].hypot(d[1])) < 0.03 * drag, "density {density}: a node at {t:.3} r moved {d:?}");
|
||||
rim += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(rim > 20, "{rim} rim nodes");
|
||||
assert!(f.sample(48.0, 32.0)[0] < -0.9 * drag, "density {density}: the centre follows the brush");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_strokes_is_identity_at_every_depth() {
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
@@ -955,6 +1149,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_resolution_preview_matches_the_unit_scale_proxy_and_partial_updates() {
|
||||
let canvas = Rect::new(0, 0, 96, 64);
|
||||
for sample_type in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let source = sample(sample_type);
|
||||
let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, 32.0);
|
||||
stroke.points = vec![vec![38.0, 30.0, 1.0], vec![52.0, 34.0, 0.8]];
|
||||
let mut field = LiquifyField::from_strokes(canvas, 2.0, &[stroke]);
|
||||
|
||||
let proxy = ProxyImage::new(&source, canvas, 96);
|
||||
assert_eq!(proxy.scale, 1.0);
|
||||
let mut want = vec![[0; 4]; proxy.w * proxy.h];
|
||||
proxy.render(&field, [0, 0, proxy.w, proxy.h], &mut want);
|
||||
|
||||
let mut got = vec![[0; 4]; proxy.w * proxy.h];
|
||||
let mut scratch = Vec::new();
|
||||
assert!(ProxyImage::render_full_resolution(&source, &field, canvas, canvas, &mut got, &mut scratch));
|
||||
assert_eq!(got, want, "full-resolution render at {sample_type:?}");
|
||||
|
||||
let mut more = LiquifyStroke::new(LiquifyTool::Bloat, 16.0);
|
||||
more.points = vec![vec![43.0, 32.0, 1.0]];
|
||||
let dirty = field.apply_stroke(&more);
|
||||
proxy.render(&field, proxy.proxy_rect(dirty), &mut want);
|
||||
assert!(ProxyImage::render_full_resolution(&source, &field, canvas, dirty, &mut got, &mut scratch));
|
||||
assert_eq!(got, want, "partial update at {sample_type:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_warp_displaces_along_the_stroke_and_reconstruct_restores() {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
|
||||
@@ -990,7 +1212,7 @@ mod tests {
|
||||
assert!(f.max_displacement() < before * 0.2, "{} vs {before}", f.max_displacement());
|
||||
// Frozen area doesn't move.
|
||||
let mut fr = LiquifyStroke::new(LiquifyTool::Freeze, 60.0);
|
||||
fr.density = 0.0;
|
||||
fr.density = 100.0;
|
||||
fr.points = vec![vec![40.0, 32.0]];
|
||||
let mut g = LiquifyField::new(bounds(), 2.0);
|
||||
g.apply_stroke(&fr);
|
||||
|
||||
@@ -224,8 +224,11 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
|
||||
morphology::morph(src, w, h, r, grow)
|
||||
}
|
||||
|
||||
mod brush;
|
||||
mod morphology;
|
||||
|
||||
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
|
||||
|
||||
/// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman:
|
||||
/// three comparisons per sample whatever the radius.
|
||||
fn running_extreme(src: &[f32], out: &mut [f32], r: usize, max: bool, g: &mut Vec<f32>, hb: &mut Vec<f32>) {
|
||||
@@ -293,10 +296,18 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec<f32> {
|
||||
|
||||
/// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`).
|
||||
pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec<f32> {
|
||||
refine_buffer_with(img, m, w, h, p, morph)
|
||||
refine_buffer_with(img, m, w, h, p, None, morph)
|
||||
}
|
||||
|
||||
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, morphology: F) -> Vec<f32>
|
||||
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
|
||||
/// box radius of its colour estimate.
|
||||
#[derive(Clone, Copy)]
|
||||
struct BrushBuf<'a> {
|
||||
v: &'a [f32],
|
||||
window: usize,
|
||||
}
|
||||
|
||||
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
@@ -324,13 +335,26 @@ where
|
||||
Some(wd) => wd[i].clamp(1.5, p.radius.max(1.5)),
|
||||
None => p.radius,
|
||||
};
|
||||
let wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0);
|
||||
let mut wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0);
|
||||
// Restored by the brush: the original edge.
|
||||
if let Some(b) = brush.and_then(|b| b.v.get(i)).filter(|b| **b < 0.0) {
|
||||
wgt *= 1.0 + b;
|
||||
}
|
||||
if wgt > 0.0 {
|
||||
a[i] = m[i] + (q[i].clamp(0.0, 1.0) - m[i]) * wgt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Added by the brush: coverage estimated from the image.
|
||||
if let (Some(b), Some(img)) = (brush.filter(|b| b.v.iter().any(|v| *v > 0.0)), img) {
|
||||
let est = brush::estimate(img, m, b.v, w, h, b.window);
|
||||
for ((ai, ei), bi) in a.iter_mut().zip(&est).zip(b.v) {
|
||||
if *bi > 0.0 {
|
||||
*ai += (ei - *ai) * bi;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ss = p.smooth_sigma();
|
||||
if ss >= 0.3 {
|
||||
a = gaussian_blur(&a, w, h, ss);
|
||||
@@ -357,7 +381,21 @@ where
|
||||
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
|
||||
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
|
||||
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
|
||||
refine_mask_with(sampler, mask, content, canvas, p, morph)
|
||||
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
|
||||
}
|
||||
|
||||
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
|
||||
/// (the area can reach beyond the edge-detection band); where it restores, the original edge
|
||||
/// stays. The sampler is read wherever the brush adds, whatever the radius.
|
||||
pub fn refine_mask_brushed(
|
||||
sampler: &dyn Sampler,
|
||||
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
|
||||
content: Rect,
|
||||
canvas: Rect,
|
||||
p: &RefineParams,
|
||||
brush: Option<&EdgeBrush>,
|
||||
) -> Option<Region> {
|
||||
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
|
||||
}
|
||||
|
||||
fn refine_mask_with<F>(
|
||||
@@ -366,6 +404,7 @@ fn refine_mask_with<F>(
|
||||
content: Rect,
|
||||
canvas: Rect,
|
||||
p: &RefineParams,
|
||||
brush: Option<&EdgeBrush>,
|
||||
morphology: F,
|
||||
) -> Option<Region>
|
||||
where
|
||||
@@ -375,8 +414,8 @@ where
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bbox = content.inflate(p.spread()).intersect(&canvas);
|
||||
let halo = p.halo();
|
||||
let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas);
|
||||
let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX));
|
||||
let mut tiles = Vec::new();
|
||||
let mut y = bbox.y0;
|
||||
while y < bbox.y1 {
|
||||
@@ -401,11 +440,17 @@ where
|
||||
}
|
||||
out
|
||||
};
|
||||
if (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
|
||||
let painted = brush.filter(|b| !b.bounds().intersect(&r).is_empty()).map(|b| b.read(r));
|
||||
let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
|
||||
if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
|
||||
return (*t, vec![q(first); cw * ch]);
|
||||
}
|
||||
let img = p.uses_image().then(|| sampler.rgb(r));
|
||||
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, morphology);
|
||||
let img = (p.uses_image() || adds).then(|| sampler.rgb(r));
|
||||
let bb = match (&painted, brush) {
|
||||
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
|
||||
_ => None,
|
||||
};
|
||||
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology);
|
||||
(*t, core(&out))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Select and Mask's Refine Edge Brush: strokes that set where edge refinement happens.
|
||||
//!
|
||||
//! Painting (`add`) marks an area as unknown: its coverage is estimated from the image, so soft
|
||||
//! detail such as hair beyond the edge-detection band is picked up. Painting with `add = false`
|
||||
//! (Photoshop's Alt) removes the refinement there and keeps the original edge. Strokes apply in
|
||||
//! order; a later one paints over an earlier one.
|
||||
//!
|
||||
//! The estimate projects each painted pixel's colour on the line between the mean colours of the
|
||||
//! known foreground and background around it (normalised convolution over a box twice the brush
|
||||
//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution
|
||||
//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use super::box_mean;
|
||||
use crate::segment::RgbImage;
|
||||
|
||||
/// One Refine Edge Brush stroke, in document pixels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EdgeStroke {
|
||||
/// The stroke's path (a single point paints a dab).
|
||||
pub points: Vec<(f32, f32)>,
|
||||
/// Brush diameter in pixels.
|
||||
pub size: f32,
|
||||
/// Add to the refinement area (true) or restore the original edge (false).
|
||||
pub add: bool,
|
||||
}
|
||||
|
||||
/// Brush diameters the strokes may use.
|
||||
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
|
||||
|
||||
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EdgeBrush {
|
||||
bbox: Rect,
|
||||
/// Coverage × 127, row-major over `bbox`.
|
||||
v: Vec<i8>,
|
||||
/// Bounds of the added (positive) coverage.
|
||||
added: Rect,
|
||||
/// Box radius of the colour estimate (pixels).
|
||||
window: usize,
|
||||
}
|
||||
|
||||
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
|
||||
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let len2 = dx * dx + dy * dy;
|
||||
let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
|
||||
let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt();
|
||||
(r + 0.5 - d).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
|
||||
fn dab_bounds(p: (f32, f32), r: f32) -> Rect {
|
||||
let lo = |v: f32| (v - r - 1.0).floor().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32;
|
||||
let hi = |v: f32| (v + r + 1.0).ceil().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32;
|
||||
Rect::new(lo(p.0), lo(p.1), hi(p.0), hi(p.1))
|
||||
}
|
||||
|
||||
/// Most pixels the strokes of one [`EdgeBrush`] may sweep (Σ length × size + size² per stroke):
|
||||
/// about eleven times a 24 MP canvas.
|
||||
pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64;
|
||||
|
||||
/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite.
|
||||
pub fn swept_area(strokes: &[EdgeStroke]) -> f64 {
|
||||
strokes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
|
||||
let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
|
||||
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
|
||||
/// kept one are dropped, and long segments are split at most `r` long, so each segment's box
|
||||
/// stays close to the area it paints.
|
||||
fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
|
||||
let Some(&first) = points.first() else { return Vec::new() };
|
||||
let min_step = (r / 4.0).max(0.5);
|
||||
let max_step = r.max(2.0);
|
||||
let mut kept = vec![first];
|
||||
for p in points.iter().skip(1) {
|
||||
let last = kept.last().copied().unwrap_or(first);
|
||||
if (p.0 - last.0).hypot(p.1 - last.1) >= min_step {
|
||||
kept.push(*p);
|
||||
}
|
||||
}
|
||||
if let (Some(last), Some(end)) = (kept.last().copied(), points.last().copied())
|
||||
&& last != end
|
||||
{
|
||||
kept.push(end);
|
||||
}
|
||||
if kept.len() == 1 {
|
||||
return vec![(first, first)];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for w in kept.windows(2) {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize;
|
||||
let at = |k: usize| {
|
||||
let t = k as f32 / pieces as f32;
|
||||
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
|
||||
};
|
||||
out.extend((0..pieces).map(|k| (at(k), at(k + 1))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl EdgeBrush {
|
||||
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
|
||||
/// than [`MAX_SWEPT_AREA`].
|
||||
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
|
||||
let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite());
|
||||
if swept_area(strokes) > MAX_SWEPT_AREA {
|
||||
return None;
|
||||
}
|
||||
let strokes: Vec<&EdgeStroke> = strokes.iter().filter(usable).collect();
|
||||
let stroke_bounds = |s: &EdgeStroke| -> Rect {
|
||||
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
|
||||
s.points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas)
|
||||
};
|
||||
let bbox = strokes.iter().fold(Rect::EMPTY, |acc, s| acc.union(&stroke_bounds(s)));
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bw = bbox.width() as usize;
|
||||
let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?];
|
||||
let mut cov: Vec<f32> = Vec::new();
|
||||
let mut max_add = 0.0f32;
|
||||
for s in strokes {
|
||||
let sb = stroke_bounds(s);
|
||||
if sb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
|
||||
if s.add {
|
||||
max_add = max_add.max(2.0 * r);
|
||||
}
|
||||
let sw = sb.width() as usize;
|
||||
cov.clear();
|
||||
cov.resize(sw * sb.height() as usize, 0.0);
|
||||
// The stroke's coverage: the most any of its segments gives (a single dab for one point).
|
||||
for (a, b) in segments(&s.points, r) {
|
||||
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb);
|
||||
for y in seg.y0..seg.y1 {
|
||||
for x in seg.x0..seg.x1 {
|
||||
let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r);
|
||||
if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize)
|
||||
&& c > *slot
|
||||
{
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = if s.add { 1.0 } else { -1.0 };
|
||||
for y in sb.y0..sb.y1 {
|
||||
for x in sb.x0..sb.x1 {
|
||||
let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0);
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) {
|
||||
let cur = f32::from(*slot) / 127.0;
|
||||
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.iter().all(|c| *c == 0) {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for (row, line) in v.chunks_exact(bw).enumerate() {
|
||||
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
|
||||
let y = bbox.y0 + row as i32;
|
||||
(x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1));
|
||||
}
|
||||
let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY };
|
||||
Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
|
||||
}
|
||||
|
||||
/// Where the strokes paint (added or restored).
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.bbox
|
||||
}
|
||||
|
||||
/// Where the strokes add to the refinement area (empty when they only restore).
|
||||
pub fn added_bounds(&self) -> Rect {
|
||||
self.added
|
||||
}
|
||||
|
||||
/// Box radius of the colour estimate (it reads up to four times as far).
|
||||
pub fn window(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Signed coverage at document pixel `(x, y)` (zero outside the painted area).
|
||||
pub fn at(&self, x: i32, y: i32) -> f32 {
|
||||
if !self.bbox.contains(x, y) {
|
||||
return 0.0;
|
||||
}
|
||||
let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize;
|
||||
self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0)
|
||||
}
|
||||
|
||||
/// Signed coverage over `r` (row-major).
|
||||
pub fn read(&self, r: Rect) -> Vec<f32> {
|
||||
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Coverage estimated from the image where `brush` (signed, `w × h`) adds: each pixel's colour
|
||||
/// projected on the line between the mean known foreground and background colours within
|
||||
/// `window` px (or `4 × window` where there are none that close). Known means outside the painted area and (nearly) fully in or out of `m`. Pixels
|
||||
/// with no foreground or background nearby, or no contrast between them, keep `m`.
|
||||
pub(super) fn estimate(img: &RgbImage, m: &[f32], brush: &[f32], w: usize, h: usize, window: usize) -> Vec<f32> {
|
||||
let n = w * h;
|
||||
let known = |want_fg: bool| -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let (mv, bv) = (m.get(i).copied().unwrap_or(0.0), brush.get(i).copied().unwrap_or(0.0));
|
||||
let ok = bv <= 0.0 && if want_fg { mv >= 0.98 } else { mv <= 0.02 };
|
||||
if ok { 1.0 } else { 0.0 }
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (wf, wb) = (known(true), known(false));
|
||||
// Normalised convolution of the known colours: weight sums and weighted colour sums, over the
|
||||
// window and (where the window finds none) four times as far.
|
||||
let means = |wt: &[f32], r: usize| -> (Vec<f32>, Vec<Vec<f32>>) {
|
||||
let sums = (0..3)
|
||||
.map(|c| {
|
||||
let v: Vec<f32> = img.px.iter().zip(wt).map(|(p, k)| p.get(c).copied().unwrap_or(0.0) * k).collect();
|
||||
box_mean(&v, w, h, r)
|
||||
})
|
||||
.collect();
|
||||
(box_mean(wt, w, h, r), sums)
|
||||
};
|
||||
let near = [means(&wf, window), means(&wb, window)];
|
||||
let far = [means(&wf, window * 4), means(&wb, window * 4)];
|
||||
// The mean known colour at `i` (0 = foreground, 1 = background).
|
||||
let mean = |k: usize, i: usize| -> Option<[f32; 3]> {
|
||||
let pick = |(d, s): &(Vec<f32>, Vec<Vec<f32>>)| -> Option<[f32; 3]> {
|
||||
let d = d.get(i).copied().filter(|d| *d >= 1e-4)?;
|
||||
Some([0, 1, 2].map(|c| s.get(c).and_then(|v| v.get(i)).copied().unwrap_or(0.0) / d))
|
||||
};
|
||||
near.get(k).and_then(pick).or_else(|| far.get(k).and_then(pick))
|
||||
};
|
||||
let mut out = m.to_vec();
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
if brush.get(i).copied().unwrap_or(0.0) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (Some(fm), Some(bm), Some(px)) = (mean(0, i), mean(1, i), img.px.get(i)) else { continue };
|
||||
let mut num = 0.0f32;
|
||||
let mut den = 0.0f32;
|
||||
for ((f, b), p) in fm.iter().zip(&bm).zip(px) {
|
||||
num += (p - b) * (f - b);
|
||||
den += (f - b) * (f - b);
|
||||
}
|
||||
if den > 1e-4 {
|
||||
*o = (num / den).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::matting::{RefineParams, refine_mask_brushed, region_reader};
|
||||
use crate::segment::ImageSampler;
|
||||
use crate::selection::Region;
|
||||
|
||||
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
|
||||
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
|
||||
#[test]
|
||||
fn brush_picks_up_strands_beyond_the_band_and_restores_the_edge() {
|
||||
let (w, h) = (120usize, 60usize);
|
||||
let (fg, bg) = ([0.9f32, 0.7, 0.2], [0.1f32, 0.3, 0.8]);
|
||||
let strand = |x: usize| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4));
|
||||
let img = RgbImage::from_fn(w, h, |x, _| if strand(x) { fg } else { bg });
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let reg = Region { bbox: canvas, mask: (0..w * h).map(|i| if i % w < 40 { 255 } else { 0 }).collect() };
|
||||
let p = RefineParams { radius: 4.0, ..Default::default() };
|
||||
let refine = |strokes: &[EdgeStroke]| {
|
||||
let brush = EdgeBrush::new(strokes, canvas);
|
||||
refine_mask_brushed(&s, ®ion_reader(®), reg.bbox, canvas, &p, brush.as_ref()).expect("refined")
|
||||
};
|
||||
let plain = refine(&[]);
|
||||
assert!(plain.at(64, 30) < 0.01, "the band alone stops short of the strands");
|
||||
let over_strands = EdgeStroke { points: vec![(70.0, 10.0), (70.0, 50.0)], size: 30.0, add: true };
|
||||
let brushed = refine(std::slice::from_ref(&over_strands));
|
||||
assert!(brushed.at(64, 30) > 0.9 && brushed.at(72, 30) > 0.9, "strands added: {}", brushed.at(64, 30));
|
||||
assert!(brushed.at(66, 30) < 0.1, "the background between them stays out: {}", brushed.at(66, 30));
|
||||
assert!(brushed.at(20, 30) > 0.99 && brushed.at(100, 30) < 0.01, "unpainted areas keep the refined selection");
|
||||
// Restoring paints the refinement out: with Shift Edge 0 the edge is the original one.
|
||||
let soft = RefineParams { radius: 4.0, feather: 0.0, ..Default::default() };
|
||||
let blurred = RgbImage::from_fn(w, h, |x, _| {
|
||||
let t = ((x as f32 - 36.0) / 8.0).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|c| fg[c] * (1.0 - t) + bg[c] * t)
|
||||
});
|
||||
let sb = ImageSampler { img: &blurred, origin: (0, 0) };
|
||||
let refined = refine_mask_brushed(&sb, ®ion_reader(®), reg.bbox, canvas, &soft, None).expect("refined");
|
||||
assert!(refined.at(41, 30) > 0.1, "the band softens the blurred edge");
|
||||
let restore = [EdgeStroke { points: vec![(40.0, 0.0), (40.0, 60.0)], size: 20.0, add: false }];
|
||||
let restored = refine_mask_brushed(&sb, ®ion_reader(®), reg.bbox, canvas, &soft, EdgeBrush::new(&restore, canvas).as_ref()).expect("refined");
|
||||
assert!(restored.at(41, 30) < 0.01 && restored.at(38, 30) > 0.99, "the original edge is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_paint_in_order_and_clip_to_the_canvas() {
|
||||
let canvas = Rect::new(0, 0, 100, 50);
|
||||
let add = EdgeStroke { points: vec![(10.0, 25.0), (90.0, 25.0)], size: 10.0, add: true };
|
||||
let b = EdgeBrush::new(std::slice::from_ref(&add), canvas).expect("painted");
|
||||
assert!(b.at(50, 25) > 0.99 && b.at(50, 40) == 0.0);
|
||||
assert_eq!(b.window(), 10);
|
||||
// A later subtract stroke paints over the add.
|
||||
let sub = EdgeStroke { points: vec![(50.0, 25.0)], size: 20.0, add: false };
|
||||
let b = EdgeBrush::new(&[add.clone(), sub], canvas).expect("painted");
|
||||
assert!(b.at(50, 25) < -0.99 && b.at(20, 25) > 0.99);
|
||||
// Off the canvas, non-finite or empty strokes paint nothing.
|
||||
let off = EdgeStroke { points: vec![(-500.0, -500.0)], size: 10.0, add: true };
|
||||
let nan = EdgeStroke { points: vec![(f32::NAN, 3.0)], size: 10.0, add: true };
|
||||
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
|
||||
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
|
||||
}
|
||||
}
|
||||
@@ -261,8 +261,8 @@ fn tiled_refinement_release_comparison() {
|
||||
let region = trim_region(Region { bbox: canvas, mask }).unwrap();
|
||||
let reader = region_reader(®ion);
|
||||
let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() };
|
||||
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, repeated).unwrap();
|
||||
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, morph).unwrap();
|
||||
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap();
|
||||
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap();
|
||||
assert_eq!(new.bbox, old.bbox);
|
||||
assert_eq!(new.mask, old.mask);
|
||||
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
|
||||
@@ -270,9 +270,9 @@ fn tiled_refinement_release_comparison() {
|
||||
let mut measure = |fast: bool| {
|
||||
let start = Instant::now();
|
||||
let out = if fast {
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, morph)
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph)
|
||||
} else {
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, repeated)
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
|
||||
}
|
||||
.unwrap();
|
||||
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Data-parallel helpers: rayon on native targets, sequential on wasm (where this crate has no
|
||||
//! threads). Each gives the same result either way, and whatever `par` says.
|
||||
|
||||
/// `f(index, chunk)` for each `len`-item chunk of `buf`, in parallel when `par`.
|
||||
pub(crate) fn chunks_mut<T: Send>(buf: &mut [T], len: usize, par: bool, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if par {
|
||||
use rayon::prelude::*;
|
||||
buf.par_chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
|
||||
return;
|
||||
}
|
||||
let _ = par;
|
||||
buf.chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
|
||||
}
|
||||
|
||||
/// [`chunks_mut`] over two buffers in step: the `i`-th `la`-item chunk of `a` with the `i`-th
|
||||
/// `lb`-item chunk of `b`.
|
||||
pub(crate) fn chunks2_mut<A: Send, B: Send>(a: &mut [A], la: usize, b: &mut [B], lb: usize, par: bool, f: impl Fn(usize, &mut [A], &mut [B]) + Sync + Send) {
|
||||
if la == 0 || lb == 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if par {
|
||||
use rayon::prelude::*;
|
||||
a.par_chunks_mut(la).zip(b.par_chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
|
||||
return;
|
||||
}
|
||||
let _ = par;
|
||||
a.chunks_mut(la).zip(b.chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
|
||||
}
|
||||
|
||||
/// `[f(0), f(1), …, f(n - 1)]`, in parallel when `par`.
|
||||
pub(crate) fn map<T: Send>(n: usize, par: bool, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if par {
|
||||
use rayon::prelude::*;
|
||||
return (0..n).into_par_iter().map(f).collect();
|
||||
}
|
||||
let _ = par;
|
||||
(0..n).map(f).collect()
|
||||
}
|
||||
|
||||
/// How many of `0..n` satisfy `f`.
|
||||
pub(crate) fn count(n: usize, f: impl Fn(usize) -> bool + Sync + Send) -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
(0..n).into_par_iter().filter(|&i| f(i)).count()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
(0..n).filter(|&i| f(i)).count()
|
||||
}
|
||||
|
||||
/// `f(item)` for each of `items`, in parallel when `par`.
|
||||
pub(crate) fn each<T: Send>(items: Vec<T>, par: bool, f: impl Fn(T) + Sync + Send) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if par {
|
||||
use rayon::prelude::*;
|
||||
items.into_par_iter().for_each(f);
|
||||
return;
|
||||
}
|
||||
let _ = par;
|
||||
items.into_iter().for_each(f);
|
||||
}
|
||||
|
||||
/// Threads the parallel helpers can use (1 on wasm).
|
||||
pub(crate) fn threads() -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
rayon::current_num_threads()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
1
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Edit › Puppet Warp: pins on a triangle mesh over the layer's opaque region, deformed
|
||||
//! as-rigidly-as-possible.
|
||||
//!
|
||||
//! * **Mesh.** The layer's coverage (alpha above a threshold, grown or shrunk by `expansion`) is
|
||||
//! rasterized onto a coarse block grid; a square grid at the density's spacing keeps every
|
||||
//! cell that touches covered blocks, split into two triangles (alternating diagonals). A
|
||||
//! grid-clipped mesh is simple, robust and uniform, which is all ARAP needs.
|
||||
//! * **Mesh.** The layer's coverage (alpha above a threshold) becomes a signed distance field,
|
||||
//! offset by `expansion` to grow or shrink the region. A square grid at the density's spacing,
|
||||
//! split into triangles, is fitted to that outline (2D isosurface stuffing): vertices near the
|
||||
//! outline snap onto it and the triangles it crosses are cut along it, so the mesh edge follows
|
||||
//! the shape, and separate shapes get separate meshes. Inside, the mesh stays a uniform grid,
|
||||
//! which is all ARAP needs.
|
||||
//! * **Deformation.** The per-triangle ARAP energy of Liu et al. 2008 (the triangle form of
|
||||
//! Sorkine & Alexa 2007), `E = Σ_t Σ_(i,j)∈t w_ij |(v_i − v_j) − R_t (x_i − x_j)|²` with
|
||||
//! cotangent weights, minimized by alternating a local step (best rotation per triangle from
|
||||
@@ -18,6 +20,8 @@
|
||||
//! * **Rendering.** Triangles are rasterized with [`crate::warp::warp_triangles`] (tile-parallel,
|
||||
//! premultiplied, bilinear/bicubic), back to front by the depth of their nearest pin.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -125,120 +129,619 @@ pub struct PuppetMesh {
|
||||
/// Coverage threshold: alpha above this counts as part of the puppet.
|
||||
const ALPHA_MIN: f32 = 0.05;
|
||||
|
||||
/// The outline field's longest side, in blocks: how finely the mesh edge follows the outline.
|
||||
const FIELD_BLOCKS: f64 = 1024.0;
|
||||
|
||||
/// The puppet's region as a signed distance field over a block grid: negative inside the
|
||||
/// opaque area grown (or shrunk) by the expansion, positive outside, in px.
|
||||
struct Field {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
/// Block size (px).
|
||||
g: f64,
|
||||
bw: usize,
|
||||
bh: usize,
|
||||
/// Signed distance from each block centre to the opaque outline (px, negative inside).
|
||||
sd: Vec<f32>,
|
||||
expansion: f64,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn new(src: &Surface, bounds: Rect, expansion: f64) -> Field {
|
||||
let long = f64::from(bounds.width().max(bounds.height()));
|
||||
let grow = expansion.max(0.0).ceil();
|
||||
let g = ((long + 2.0 * grow + 8.0) / FIELD_BLOCKS).ceil().max(1.0) as i32;
|
||||
let area = bounds.inflate(grow as i32 + 2 * g);
|
||||
let bw = ((area.width() as i32 + g - 1) / g).max(1) as usize;
|
||||
let bh = ((area.height() as i32 + g - 1) / g).max(1) as usize;
|
||||
let mut cov = vec![false; bw * bh];
|
||||
let fmt = src.format();
|
||||
let n = fmt.channels();
|
||||
let block = |bx: usize, by: usize| {
|
||||
let (x, y) = (area.x0 + bx as i32 * g, area.y0 + by as i32 * g);
|
||||
Rect::new(x, y, x + g, y + g)
|
||||
};
|
||||
if !fmt.alpha {
|
||||
for (i, c) in cov.iter_mut().enumerate() {
|
||||
*c = !block(i % bw, i / bw).intersect(&bounds).is_empty();
|
||||
}
|
||||
} else {
|
||||
// One block row per task (rows are independent).
|
||||
let scan = |by: usize, out: &mut [bool]| {
|
||||
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
|
||||
if r.is_empty() || !src.has_tiles_in(r) {
|
||||
return;
|
||||
}
|
||||
let row = src.read_region(r);
|
||||
let rw = r.width() as usize;
|
||||
for (i, p) in row.chunks_exact(n).enumerate() {
|
||||
if p.last().is_some_and(|&a| a > ALPHA_MIN) {
|
||||
let x = r.x0 + (i % rw) as i32;
|
||||
if let Some(o) = out.get_mut(((x - area.x0) / g) as usize) {
|
||||
*o = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
|
||||
}
|
||||
let outside = edt(&cov, true, bw, bh);
|
||||
let inside = edt(&cov, false, bw, bh);
|
||||
let gf = f64::from(g);
|
||||
let sd = cov
|
||||
.iter()
|
||||
.zip(outside.iter().zip(&inside))
|
||||
.map(|(&c, (&o, &i))| if c { (-(i.sqrt() - 0.5) * gf) as f32 } else { ((o.sqrt() - 0.5) * gf) as f32 })
|
||||
.collect();
|
||||
Field { x0: f64::from(area.x0), y0: f64::from(area.y0), g: gf, bw, bh, sd, expansion }
|
||||
}
|
||||
|
||||
/// The field at a block centre (exact, no interpolation).
|
||||
fn at(&self, bx: usize, by: usize) -> f64 {
|
||||
self.sd.get(by * self.bw + bx).map_or(f64::MAX, |&d| f64::from(d) - self.expansion)
|
||||
}
|
||||
|
||||
/// The field at `p` (bilinear between block centres; clamped to the grid, which ends
|
||||
/// outside the region).
|
||||
fn value(&self, p: [f64; 2]) -> f64 {
|
||||
let fx = ((p[0] - self.x0) / self.g - 0.5).clamp(0.0, (self.bw - 1) as f64);
|
||||
let fy = ((p[1] - self.y0) / self.g - 0.5).clamp(0.0, (self.bh - 1) as f64);
|
||||
let (ix, iy) = (fx.floor() as usize, fy.floor() as usize);
|
||||
let (ix1, iy1) = ((ix + 1).min(self.bw - 1), (iy + 1).min(self.bh - 1));
|
||||
let (tx, ty) = (fx - ix as f64, fy - iy as f64);
|
||||
let top = self.at(ix, iy) * (1.0 - tx) + self.at(ix1, iy) * tx;
|
||||
let bottom = self.at(ix, iy1) * (1.0 - tx) + self.at(ix1, iy1) * tx;
|
||||
top * (1.0 - ty) + bottom * ty
|
||||
}
|
||||
|
||||
/// The outline (where the field crosses zero) as closed loops, traced by marching squares
|
||||
/// over the block centres. The field's border blocks lie outside, so every loop closes.
|
||||
fn outline(&self) -> Vec<Vec<[f64; 2]>> {
|
||||
let (bw, bh) = (self.bw, self.bh);
|
||||
if bw < 2 || bh < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let inside = |x: usize, y: usize| self.at(x, y) < 0.0;
|
||||
// A crossing is named by its block edge: 2·block for the edge to the right neighbour,
|
||||
// 2·block + 1 for the edge to the one below.
|
||||
let id = |x: usize, y: usize, down: bool| 2 * (y * bw + x) + usize::from(down);
|
||||
let point = |id: usize| {
|
||||
let b = id / 2;
|
||||
let (x, y) = (b % bw, b / bw);
|
||||
let (x2, y2) = if id.is_multiple_of(2) { (x + 1, y) } else { (x, y + 1) };
|
||||
let (fa, fb) = (self.at(x, y), self.at(x2, y2));
|
||||
let t = if (fa - fb).abs() > 1e-12 { (fa / (fa - fb)).clamp(0.0, 1.0) } else { 0.5 };
|
||||
let (dx, dy) = ((x2 - x) as f64, (y2 - y) as f64);
|
||||
[self.x0 + (x as f64 + 0.5 + dx * t) * self.g, self.y0 + (y as f64 + 0.5 + dy * t) * self.g]
|
||||
};
|
||||
let mut links: HashMap<usize, [usize; 2]> = HashMap::new();
|
||||
let mut link = |a: usize, b: usize| {
|
||||
for (p, q) in [(a, b), (b, a)] {
|
||||
let e = links.entry(p).or_insert([usize::MAX; 2]);
|
||||
if e[0] == usize::MAX {
|
||||
e[0] = q;
|
||||
} else {
|
||||
e[1] = q;
|
||||
}
|
||||
}
|
||||
};
|
||||
for y in 0..bh - 1 {
|
||||
for x in 0..bw - 1 {
|
||||
let c = [inside(x, y), inside(x + 1, y), inside(x + 1, y + 1), inside(x, y + 1)];
|
||||
if c.iter().all(|&v| v == c[0]) {
|
||||
continue;
|
||||
}
|
||||
// Top, right, bottom, left.
|
||||
let edges = [id(x, y, false), id(x + 1, y, true), id(x, y + 1, false), id(x, y, true)];
|
||||
let cross = [c[0] != c[1], c[1] != c[2], c[3] != c[2], c[0] != c[3]];
|
||||
let ks: Vec<usize> = (0..4).filter(|&k| cross[k]).collect();
|
||||
if let [a, b] = ks[..] {
|
||||
link(edges[a], edges[b]);
|
||||
} else if ks.len() == 4 {
|
||||
// A saddle: the centre decides which diagonal pair is joined.
|
||||
let centre = self.at(x, y) + self.at(x + 1, y) + self.at(x + 1, y + 1) + self.at(x, y + 1) < 0.0;
|
||||
if c[0] == centre {
|
||||
link(edges[0], edges[1]);
|
||||
link(edges[2], edges[3]);
|
||||
} else {
|
||||
link(edges[3], edges[0]);
|
||||
link(edges[1], edges[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut keys: Vec<usize> = links.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
let mut seen: HashSet<usize> = HashSet::with_capacity(keys.len());
|
||||
let mut loops = Vec::new();
|
||||
for &start in &keys {
|
||||
if seen.contains(&start) {
|
||||
continue;
|
||||
}
|
||||
let mut lp = Vec::new();
|
||||
let (mut prev, mut cur) = (usize::MAX, start);
|
||||
loop {
|
||||
seen.insert(cur);
|
||||
lp.push(point(cur));
|
||||
let Some(&[a, b]) = links.get(&cur) else { break };
|
||||
let next = if a != prev { a } else { b };
|
||||
if next == usize::MAX || seen.contains(&next) {
|
||||
break;
|
||||
}
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
if lp.len() >= 3 {
|
||||
loops.push(lp);
|
||||
}
|
||||
}
|
||||
loops
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared Euclidean distance (in blocks) from every block to the nearest block whose `mask`
|
||||
/// equals `site` (Felzenszwalb & Huttenlocher's separable lower-envelope transform). About
|
||||
/// 1e20 when there is none.
|
||||
fn edt(mask: &[bool], site: bool, w: usize, h: usize) -> Vec<f64> {
|
||||
const FAR: f64 = 1e20;
|
||||
let mut d: Vec<f64> = mask.iter().map(|&m| if m == site { 0.0 } else { FAR }).collect();
|
||||
let mut f = vec![0.0; w.max(h)];
|
||||
let mut out = vec![0.0; w.max(h)];
|
||||
let mut v = vec![0usize; w.max(h)];
|
||||
let mut z = vec![0.0; w.max(h) + 1];
|
||||
for x in 0..w {
|
||||
for (y, fy) in f[..h].iter_mut().enumerate() {
|
||||
*fy = d[y * w + x];
|
||||
}
|
||||
edt_1d(&f[..h], &mut out[..h], &mut v, &mut z);
|
||||
for (y, o) in out[..h].iter().enumerate() {
|
||||
d[y * w + x] = *o;
|
||||
}
|
||||
}
|
||||
for row in d.chunks_mut(w) {
|
||||
f[..w].copy_from_slice(row);
|
||||
edt_1d(&f[..w], row, &mut v, &mut z);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// One-dimensional squared distance transform of the sampled function `f`.
|
||||
fn edt_1d(f: &[f64], out: &mut [f64], v: &mut [usize], z: &mut [f64]) {
|
||||
let n = f.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let mut k = 0usize;
|
||||
v[0] = 0;
|
||||
z[0] = f64::NEG_INFINITY;
|
||||
z[1] = f64::INFINITY;
|
||||
let meet = |q: usize, p: usize| {
|
||||
let (qf, pf) = (q as f64, p as f64);
|
||||
((f[q] + qf * qf) - (f[p] + pf * pf)) / (2.0 * (qf - pf))
|
||||
};
|
||||
for q in 1..n {
|
||||
let mut s = meet(q, v[k]);
|
||||
while k > 0 && s <= z[k] {
|
||||
k -= 1;
|
||||
s = meet(q, v[k]);
|
||||
}
|
||||
k += 1;
|
||||
v[k] = q;
|
||||
z[k] = s;
|
||||
z[k + 1] = f64::INFINITY;
|
||||
}
|
||||
k = 0;
|
||||
for (q, o) in out.iter_mut().enumerate() {
|
||||
let qf = q as f64;
|
||||
while z[k + 1] < qf {
|
||||
k += 1;
|
||||
}
|
||||
let p = v[k];
|
||||
*o = (qf - p as f64).powi(2) + f[p];
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `p` to the segment `a`–`b`.
|
||||
fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
dist2(p, [a[0] + dx * t, a[1] + dy * t]).sqrt()
|
||||
}
|
||||
|
||||
/// Simplifies a closed loop (Douglas–Peucker, within `tol` px), which keeps its corners.
|
||||
fn simplify(lp: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
|
||||
let n = lp.len();
|
||||
if n < 4 {
|
||||
return lp.to_vec();
|
||||
}
|
||||
let far = (1..n).max_by(|&a, &b| dist2(lp[0], lp[a]).total_cmp(&dist2(lp[0], lp[b]))).unwrap_or(n / 2);
|
||||
let mut keep = vec![false; n];
|
||||
keep[0] = true;
|
||||
keep[far] = true;
|
||||
// Spans (a, b) of the loop; b == n stands for point 0.
|
||||
let mut stack = vec![(0, far), (far, n)];
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
let (pa, pb) = (lp[a], lp[b % n]);
|
||||
let mut best = (0.0, 0);
|
||||
for (i, &p) in lp.iter().enumerate().take(b).skip(a + 1) {
|
||||
let d = seg_dist(p, pa, pb);
|
||||
if d > best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
}
|
||||
if best.0 > tol {
|
||||
keep[best.1] = true;
|
||||
stack.push((a, best.1));
|
||||
stack.push((best.1, b));
|
||||
}
|
||||
}
|
||||
let out: Vec<[f64; 2]> = (0..n).filter(|&i| keep[i]).map(|i| lp[i]).collect();
|
||||
if out.len() >= 3 {
|
||||
return out;
|
||||
}
|
||||
// A speck thinner than the tolerance: keep a few of its points.
|
||||
(0..n).step_by((n / 4).max(1)).map(|i| lp[i]).collect()
|
||||
}
|
||||
|
||||
/// Splits the sides of a closed loop longer than `step` into equal parts.
|
||||
fn densify(lp: &[[f64; 2]], step: f64) -> Vec<[f64; 2]> {
|
||||
let mut out = Vec::with_capacity(lp.len());
|
||||
for (k, &a) in lp.iter().enumerate() {
|
||||
let b = lp[(k + 1) % lp.len()];
|
||||
let parts = (dist2(a, b).sqrt() / step).ceil().clamp(1.0, 1e4) as usize;
|
||||
for i in 0..parts {
|
||||
out.push(lerp(a, b, i as f64 / parts as f64));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
}
|
||||
|
||||
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
}
|
||||
|
||||
/// Positive when `d` lies inside the circumcircle of the positively oriented `a`, `b`, `c`.
|
||||
fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 {
|
||||
let (ax, ay) = (a[0] - d[0], a[1] - d[1]);
|
||||
let (bx, by) = (b[0] - d[0], b[1] - d[1]);
|
||||
let (cx, cy) = (c[0] - d[0], c[1] - d[1]);
|
||||
(ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay)
|
||||
}
|
||||
|
||||
const NONE: usize = usize::MAX;
|
||||
|
||||
/// A triangle of the Delaunay triangulation: vertices (positively oriented) and the neighbour
|
||||
/// across the edge opposite each vertex.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DTri {
|
||||
v: [usize; 3],
|
||||
n: [usize; 3],
|
||||
alive: bool,
|
||||
}
|
||||
|
||||
/// Incremental Delaunay triangulation (Bowyer–Watson), started from a triangle around the area.
|
||||
/// Vertices 0..3 are that triangle's.
|
||||
struct Delaunay {
|
||||
pts: Vec<[f64; 2]>,
|
||||
tris: Vec<DTri>,
|
||||
stamp: Vec<u32>,
|
||||
pass: u32,
|
||||
last: usize,
|
||||
}
|
||||
|
||||
impl Delaunay {
|
||||
fn new(lo: [f64; 2], hi: [f64; 2]) -> Delaunay {
|
||||
let (cx, cy) = (0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1]));
|
||||
let d = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1.0) * 4.0;
|
||||
let mut pts = vec![[cx - 2.0 * d, cy - d], [cx + 2.0 * d, cy - d], [cx, cy + 2.0 * d]];
|
||||
if orient(pts[0], pts[1], pts[2]) < 0.0 {
|
||||
pts.swap(1, 2);
|
||||
}
|
||||
Delaunay { pts, tris: vec![DTri { v: [0, 1, 2], n: [NONE; 3], alive: true }], stamp: vec![0], pass: 0, last: 0 }
|
||||
}
|
||||
|
||||
/// The triangle holding `p`: a visibility walk from the last one made.
|
||||
fn locate(&self, p: [f64; 2]) -> Option<usize> {
|
||||
let mut t = if self.tris.get(self.last).is_some_and(|t| t.alive) { self.last } else { self.tris.iter().position(|t| t.alive)? };
|
||||
for _ in 0..self.tris.len() + 3 {
|
||||
let tri = self.tris.get(t)?;
|
||||
let step = (0..3).find(|&k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) < 0.0);
|
||||
match step {
|
||||
None => return Some(t),
|
||||
Some(k) if tri.n[k] != NONE => t = tri.n[k],
|
||||
Some(_) => return None,
|
||||
}
|
||||
}
|
||||
self.tris.iter().position(|tri| tri.alive && (0..3).all(|k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) >= 0.0))
|
||||
}
|
||||
|
||||
/// Inserts `p`; returns its vertex index (an existing vertex's when `p` coincides with it).
|
||||
fn insert(&mut self, p: [f64; 2]) -> Option<usize> {
|
||||
let t0 = self.locate(p)?;
|
||||
if let Some(&v) = self.tris[t0].v.iter().find(|&&v| dist2(self.pts[v], p) < 1e-12) {
|
||||
return Some(v);
|
||||
}
|
||||
self.pass = self.pass.wrapping_add(1);
|
||||
let pass = self.pass;
|
||||
// The cavity: every triangle whose circumcircle holds `p`, grown from `t0`.
|
||||
let mut bad = vec![t0];
|
||||
self.stamp[t0] = pass;
|
||||
let mut i = 0;
|
||||
while let Some(&t) = bad.get(i) {
|
||||
i += 1;
|
||||
for k in 0..3 {
|
||||
let nb = self.tris[t].n[k];
|
||||
if nb == NONE || self.stamp[nb] == pass {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c] = self.tris[nb].v.map(|v| self.pts[v]);
|
||||
if in_circle(a, b, c, p) > 0.0 {
|
||||
self.stamp[nb] = pass;
|
||||
bad.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rim: Vec<(usize, usize, usize)> = Vec::new();
|
||||
for &t in &bad {
|
||||
let tri = self.tris[t];
|
||||
for k in 0..3 {
|
||||
let nb = tri.n[k];
|
||||
if nb == NONE || self.stamp[nb] != pass {
|
||||
rim.push((tri.v[(k + 1) % 3], tri.v[(k + 2) % 3], nb));
|
||||
}
|
||||
}
|
||||
self.tris[t].alive = false;
|
||||
}
|
||||
let pi = self.pts.len();
|
||||
self.pts.push(p);
|
||||
let base = self.tris.len();
|
||||
for (i, &(a, b, o)) in rim.iter().enumerate() {
|
||||
self.tris.push(DTri { v: [a, b, pi], n: [NONE, NONE, o], alive: true });
|
||||
self.stamp.push(0);
|
||||
if let Some(ot) = self.tris.get_mut(o) {
|
||||
for k in 0..3 {
|
||||
let (x, y) = (ot.v[(k + 1) % 3], ot.v[(k + 2) % 3]);
|
||||
if (x == b && y == a) || (x == a && y == b) {
|
||||
ot.n[k] = base + i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, &(a, b, _)) in rim.iter().enumerate() {
|
||||
let across_b = rim.iter().position(|r| r.0 == b).map_or(NONE, |j| base + j);
|
||||
let across_a = rim.iter().position(|r| r.1 == a).map_or(NONE, |j| base + j);
|
||||
self.tris[base + i].n[0] = across_b;
|
||||
self.tris[base + i].n[1] = across_a;
|
||||
}
|
||||
self.last = base;
|
||||
Some(pi)
|
||||
}
|
||||
|
||||
fn edges(&self) -> HashSet<(usize, usize)> {
|
||||
let mut e = HashSet::new();
|
||||
for t in self.tris.iter().filter(|t| t.alive) {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t.v[k], t.v[(k + 1) % 3]);
|
||||
e.insert((a.min(b), a.max(b)));
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
/// Even-odd point-in-polygon test against closed outline segments, bucketed by row.
|
||||
struct Inside<'a> {
|
||||
pts: &'a [[f64; 2]],
|
||||
segs: &'a [(usize, usize)],
|
||||
y0: f64,
|
||||
row: f64,
|
||||
rows: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> Inside<'a> {
|
||||
fn new(pts: &'a [[f64; 2]], segs: &'a [(usize, usize)], row: f64) -> Self {
|
||||
let y0 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MAX, f64::min);
|
||||
let y1 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MIN, f64::max);
|
||||
let n = if y1 >= y0 { ((y1 - y0) / row).floor().clamp(0.0, 1e5) as usize + 1 } else { 0 };
|
||||
let mut rows = vec![Vec::new(); n];
|
||||
for (i, &(a, b)) in segs.iter().enumerate() {
|
||||
let (lo, hi) = (pts[a][1].min(pts[b][1]), pts[a][1].max(pts[b][1]));
|
||||
let (r0, r1) = (((lo - y0) / row).floor() as usize, ((hi - y0) / row).floor() as usize);
|
||||
for r in rows.iter_mut().take(r1 + 1).skip(r0) {
|
||||
r.push(i);
|
||||
}
|
||||
}
|
||||
Inside { pts, segs, y0, row, rows }
|
||||
}
|
||||
|
||||
fn contains(&self, p: [f64; 2]) -> bool {
|
||||
if p[1] < self.y0 {
|
||||
return false;
|
||||
}
|
||||
let Some(row) = self.rows.get(((p[1] - self.y0) / self.row).floor() as usize) else { return false };
|
||||
let mut odd = false;
|
||||
for &i in row {
|
||||
let (a, b) = (self.pts[self.segs[i].0], self.pts[self.segs[i].1]);
|
||||
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
|
||||
odd = !odd;
|
||||
}
|
||||
}
|
||||
odd
|
||||
}
|
||||
}
|
||||
|
||||
/// Most outline points the mesh takes; a busier outline is simplified more coarsely.
|
||||
const MAX_OUTLINE_POINTS: usize = 20_000;
|
||||
|
||||
/// Builds the mesh over the covered part of `src` inside `bounds`.
|
||||
///
|
||||
/// The outline of the opaque region grown by `expansion` is traced, simplified (corners kept)
|
||||
/// and split into sides no longer than the density's spacing; a triangular lattice at that
|
||||
/// spacing fills the inside, at least half a step from the outline. The mesh is the
|
||||
/// Delaunay triangulation of those points with the outline's sides recovered (split until
|
||||
/// each is an edge), minus the triangles outside the outline.
|
||||
pub fn build_mesh(src: &Surface, bounds: Rect, density: PuppetDensity, expansion: f64) -> PuppetMesh {
|
||||
if bounds.is_empty() {
|
||||
return PuppetMesh::default();
|
||||
}
|
||||
let long = f64::from(bounds.width().max(bounds.height()));
|
||||
let e = if expansion.is_finite() { expansion.clamp(-200.0, 200.0) } else { 0.0 };
|
||||
// The density counts steps across the region the mesh covers (grown by the expansion).
|
||||
let long = f64::from(bounds.width().max(bounds.height())) + 2.0 * e.max(0.0);
|
||||
let spacing = (long / density.cells()).max(2.0);
|
||||
let e = expansion.clamp(-200.0, 200.0);
|
||||
// Coverage on a block grid (block = a quarter cell or less).
|
||||
let g = ((spacing / 4.0).floor() as i32).max(1);
|
||||
let pad = e.max(0.0).ceil() as i32 + g;
|
||||
let area = bounds.inflate(pad);
|
||||
let bw = (area.width() as i32 + g - 1) / g;
|
||||
let bh = (area.height() as i32 + g - 1) / g;
|
||||
let (bw, bh) = (bw as usize, bh as usize);
|
||||
let mut cov = vec![false; bw * bh];
|
||||
let fmt = src.format();
|
||||
let n = fmt.channels();
|
||||
if !fmt.alpha {
|
||||
for by in 0..bh {
|
||||
for bx in 0..bw {
|
||||
let r = Rect::new(area.x0 + bx as i32 * g, area.y0 + by as i32 * g, area.x0 + (bx as i32 + 1) * g, area.y0 + (by as i32 + 1) * g);
|
||||
cov[by * bw + bx] = !r.intersect(&bounds).is_empty();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// One block row per task (rows are independent).
|
||||
let scan = |by: usize, out: &mut [bool]| {
|
||||
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
|
||||
if r.is_empty() || !src.has_tiles_in(r) {
|
||||
return;
|
||||
}
|
||||
let row = src.read_region(r);
|
||||
let rw = r.width() as usize;
|
||||
for (i, p) in row.chunks_exact(n).enumerate() {
|
||||
if p[n - 1] > ALPHA_MIN {
|
||||
let x = r.x0 + (i % rw) as i32;
|
||||
out[((x - area.x0) / g) as usize] = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
|
||||
let field = Field::new(src, bounds, e);
|
||||
let loops = field.outline();
|
||||
let empty = PuppetMesh { spacing, ..Default::default() };
|
||||
if loops.is_empty() {
|
||||
return empty;
|
||||
}
|
||||
// Grow / shrink by the expansion (round stamp in block units).
|
||||
let eb = (e.abs() / f64::from(g)).round() as i32;
|
||||
if eb > 0 {
|
||||
let src_cov = cov.clone();
|
||||
let grow = e > 0.0;
|
||||
for by in 0..bh as i32 {
|
||||
for bx in 0..bw as i32 {
|
||||
let mut hit = !grow;
|
||||
'stamp: for dy in -eb..=eb {
|
||||
for dx in -eb..=eb {
|
||||
if dx * dx + dy * dy > eb * eb {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (bx + dx, by + dy);
|
||||
let v = x >= 0 && y >= 0 && x < bw as i32 && y < bh as i32 && src_cov[y as usize * bw + x as usize];
|
||||
if grow && v {
|
||||
hit = true;
|
||||
break 'stamp;
|
||||
}
|
||||
if !grow && !v {
|
||||
hit = false;
|
||||
break 'stamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
cov[by as usize * bw + bx as usize] = hit;
|
||||
let mut tol = (0.5 * field.g).max(0.5);
|
||||
let rings = loop {
|
||||
let rings: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| densify(&simplify(l, tol), spacing)).collect();
|
||||
if rings.iter().map(Vec::len).sum::<usize>() <= MAX_OUTLINE_POINTS || tol > spacing {
|
||||
break rings;
|
||||
}
|
||||
tol *= 2.0;
|
||||
};
|
||||
let (lo, hi) = ([field.x0, field.y0], [field.x0 + field.bw as f64 * field.g, field.y0 + field.bh as f64 * field.g]);
|
||||
let mut dl = Delaunay::new(lo, hi);
|
||||
let mut segs: Vec<(usize, usize)> = Vec::new();
|
||||
for ring in &rings {
|
||||
let idx: Vec<usize> = ring.iter().filter_map(|&p| dl.insert(p)).collect();
|
||||
for (k, &a) in idx.iter().enumerate() {
|
||||
let b = idx[(k + 1) % idx.len()];
|
||||
if a != b {
|
||||
segs.push((a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grid cells touching covered blocks.
|
||||
let ox = f64::from(area.x0);
|
||||
let oy = f64::from(area.y0);
|
||||
let cw = (f64::from(bw as i32 * g) / spacing).ceil() as usize;
|
||||
let ch = (f64::from(bh as i32 * g) / spacing).ceil() as usize;
|
||||
let mut index = vec![usize::MAX; (cw + 1) * (ch + 1)];
|
||||
let mut mesh = PuppetMesh { spacing, ..Default::default() };
|
||||
for cj in 0..ch {
|
||||
for ci in 0..cw {
|
||||
let bx0 = ((ci as f64 * spacing) / f64::from(g)).floor() as usize;
|
||||
let by0 = ((cj as f64 * spacing) / f64::from(g)).floor() as usize;
|
||||
let bx1 = (((ci + 1) as f64 * spacing) / f64::from(g)).ceil().min(bw as f64) as usize;
|
||||
let by1 = (((cj + 1) as f64 * spacing) / f64::from(g)).ceil().min(bh as f64) as usize;
|
||||
let any = (by0..by1).any(|y| (bx0..bx1).any(|x| cov[y * bw + x]));
|
||||
if !any {
|
||||
// The inside: a triangular lattice, kept half a step clear of the outline.
|
||||
let rise = spacing * 3f64.sqrt() / 2.0;
|
||||
let rows = ((hi[1] - lo[1]) / rise).ceil() as usize;
|
||||
let cols = ((hi[0] - lo[0]) / spacing).ceil() as usize;
|
||||
for j in 0..=rows {
|
||||
for i in 0..=cols {
|
||||
let p = [lo[0] + (i as f64 + 0.5 * (j % 2) as f64) * spacing, lo[1] + j as f64 * rise];
|
||||
if field.value(p) < -0.5 * spacing {
|
||||
dl.insert(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recover the outline's sides: split a side the triangulation lacks at its midpoint.
|
||||
let mut budget = segs.len() * 2;
|
||||
for _ in 0..8 {
|
||||
let edges = dl.edges();
|
||||
let mut next = Vec::with_capacity(segs.len());
|
||||
let mut split = false;
|
||||
for &(a, b) in &segs {
|
||||
if budget == 0 || edges.contains(&(a.min(b), a.max(b))) {
|
||||
next.push((a, b));
|
||||
continue;
|
||||
}
|
||||
let mut v = |i: usize, j: usize| {
|
||||
let k = j * (cw + 1) + i;
|
||||
if index[k] == usize::MAX {
|
||||
index[k] = mesh.verts.len();
|
||||
mesh.verts.push([ox + i as f64 * spacing, oy + j as f64 * spacing]);
|
||||
match dl.insert(lerp(dl.pts[a], dl.pts[b], 0.5)) {
|
||||
Some(c) if c != a && c != b => {
|
||||
next.push((a, c));
|
||||
next.push((c, b));
|
||||
budget -= 1;
|
||||
split = true;
|
||||
}
|
||||
index[k]
|
||||
};
|
||||
let (a, b, c, d) = (v(ci, cj), v(ci + 1, cj), v(ci + 1, cj + 1), v(ci, cj + 1));
|
||||
if (ci + cj) % 2 == 0 {
|
||||
mesh.tris.push([a, b, c]);
|
||||
mesh.tris.push([a, c, d]);
|
||||
} else {
|
||||
mesh.tris.push([a, b, d]);
|
||||
mesh.tris.push([b, c, d]);
|
||||
_ => next.push((a, b)),
|
||||
}
|
||||
}
|
||||
segs = next;
|
||||
if !split {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let inside = Inside::new(&dl.pts, &segs, spacing);
|
||||
let mut tris: Vec<[usize; 3]> = Vec::new();
|
||||
for t in dl.tris.iter().filter(|t| t.alive && t.v.iter().all(|&v| v >= 3)) {
|
||||
let [a, b, c] = t.v.map(|v| dl.pts[v]);
|
||||
if orient(a, b, c) > 1e-9 && inside.contains([(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0]) {
|
||||
tris.push(t.v);
|
||||
}
|
||||
}
|
||||
if tris.is_empty() {
|
||||
return empty;
|
||||
}
|
||||
// Keep the vertices in use, numbered for a narrow band in the solver's matrix.
|
||||
let pos = &dl.pts;
|
||||
let order = band_order(pos, &tris);
|
||||
let mut remap = vec![usize::MAX; pos.len()];
|
||||
for (new, &old) in order.iter().enumerate() {
|
||||
remap[old] = new;
|
||||
}
|
||||
PuppetMesh { verts: order.iter().map(|&i| pos[i]).collect(), tris: tris.iter().map(|t| t.map(|i| remap[i])).collect(), spacing }
|
||||
}
|
||||
|
||||
/// The vertices `tris` use, in reverse Cuthill–McKee order (breadth first from the topmost
|
||||
/// vertex of each piece, lower degrees first), which keeps the solver's banded factor small.
|
||||
fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); pos.len()];
|
||||
for t in tris {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
adj[a].push(b);
|
||||
adj[b].push(a);
|
||||
}
|
||||
}
|
||||
for a in &mut adj {
|
||||
a.sort_unstable();
|
||||
a.dedup();
|
||||
}
|
||||
let mut starts: Vec<usize> = (0..pos.len()).filter(|&i| !adj[i].is_empty()).collect();
|
||||
starts.sort_by(|&a, &b| pos[a][1].total_cmp(&pos[b][1]).then(pos[a][0].total_cmp(&pos[b][0])));
|
||||
let mut seen = vec![false; pos.len()];
|
||||
let mut order = Vec::with_capacity(starts.len());
|
||||
for s in starts {
|
||||
if seen[s] {
|
||||
continue;
|
||||
}
|
||||
seen[s] = true;
|
||||
let first = order.len();
|
||||
order.push(s);
|
||||
let mut i = first;
|
||||
while let Some(&v) = order.get(i) {
|
||||
i += 1;
|
||||
let mut next: Vec<usize> = adj[v].iter().copied().filter(|&w| !seen[w]).collect();
|
||||
next.sort_by_key(|&w| adj[w].len());
|
||||
for w in next {
|
||||
seen[w] = true;
|
||||
order.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh
|
||||
order.reverse();
|
||||
order
|
||||
}
|
||||
|
||||
/// A compressed sparse row matrix (symmetric, used by CG).
|
||||
@@ -330,9 +833,9 @@ impl Csr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Banded Cholesky factor `A = L Lᵀ` (grid meshes number their vertices row by row, so the
|
||||
/// system's bandwidth is about two mesh rows): factored once per pin set, then each global step
|
||||
/// is two triangular solves.
|
||||
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
|
||||
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
|
||||
/// set, then each global step is two triangular solves.
|
||||
struct BandChol {
|
||||
n: usize,
|
||||
p: usize,
|
||||
@@ -833,6 +1336,43 @@ mod tests {
|
||||
assert!(more.tris.len() > m.tris.len() && m.tris.len() > fewer.tris.len());
|
||||
}
|
||||
|
||||
fn in_mesh(m: &PuppetMesh, p: [f64; 2]) -> bool {
|
||||
m.tris.iter().any(|t| {
|
||||
let [a, b, c] = t.map(|i| m.verts[i]);
|
||||
let (d0, d1, d2) = (orient(a, b, p), orient(b, c, p), orient(c, a, p));
|
||||
(d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_follows_the_outline() {
|
||||
// #3105: the mesh was every grid cell touching the shape, a staircase up to a cell
|
||||
// outside it. It now follows the outline grown by the expansion.
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
|
||||
for y in 40..160 {
|
||||
for x in 40..160 {
|
||||
if (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0) <= 60.0 {
|
||||
s.write_pixel(x, y, &[0.3, 0.6, 0.9, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = build_mesh(&s, s.content_bounds(), PuppetDensity::Normal, 2.0);
|
||||
let r = |v: &[f64; 2]| (v[0] - 100.0).hypot(v[1] - 100.0);
|
||||
assert!(m.verts.iter().all(|v| r(v) <= 63.0), "a vertex outside the grown disc");
|
||||
assert!(m.verts.iter().filter(|v| r(v) > 61.0).count() >= 40, "the outline carries vertices");
|
||||
let area: f64 = m.tris.iter().map(|t| orient(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]).abs() / 2.0).sum();
|
||||
let disc = std::f64::consts::PI * 62.0 * 62.0;
|
||||
assert!((area / disc - 1.0).abs() < 0.03, "{area} vs {disc}");
|
||||
// Sharp corners and a concave corner: every opaque pixel is still inside the mesh, even
|
||||
// without an expansion, and the empty corner of the L is not.
|
||||
let l = blob(SampleType::U8);
|
||||
let m = build_mesh(&l, l.content_bounds(), PuppetDensity::Fewer, 0.0);
|
||||
for (x, y) in (20..36).flat_map(|x| (10..70).map(move |y| (x, y))).chain((36..70).flat_map(|x| (56..70).map(move |y| (x, y)))) {
|
||||
assert!(in_mesh(&m, [f64::from(x) + 0.5, f64::from(y) + 0.5]), "({x}, {y}) is outside the mesh");
|
||||
}
|
||||
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pins_or_unmoved_pins_are_identity() {
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
//!
|
||||
//! Weights come from [`stack_weights`] (focus stacking: per pixel, the sharpest image wins, by
|
||||
//! the smoothed magnitude of the luminance Laplacian) or [`panorama_weights`] (each pixel goes to
|
||||
//! the image whose opaque area it is deepest inside). Buffers are interleaved `w × h × ch`
|
||||
//! normalised floats of any colour model and depth; deterministic.
|
||||
//! the image whose opaque area it is deepest inside).
|
||||
//!
|
||||
//! [`fuse_stack`] is the focus stack's merge: E. H. Adelson, C. H. Anderson, J. R. Bergen,
|
||||
//! P. J. Burt, J. M. Ogden, *Pyramid methods in image processing*, RCA Engineer 1984 (the
|
||||
//! multifocus composite: per pyramid coefficient, keep the image with the most band-pass energy,
|
||||
//! then expand-and-add) with the selection rules of W. Wang, F. Chang, *A Multi-focus Image Fusion
|
||||
//! Method Based on Laplacian Pyramid*, Journal of Computers 6(12), 2011 (maximum region energy
|
||||
//! for the band-pass levels, local deviation and entropy for the residual), plus halo control.
|
||||
//!
|
||||
//! Buffers are interleaved `w × h × ch` normalised floats of any colour model and depth;
|
||||
//! deterministic.
|
||||
|
||||
/// One pyramid level.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -18,7 +27,7 @@ pub struct Level {
|
||||
pub px: Vec<f32>,
|
||||
}
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
use crate::photo_util::{par_map, par_rows, par_rows2};
|
||||
|
||||
const K: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
|
||||
|
||||
@@ -169,6 +178,349 @@ pub fn blend(w: usize, h: usize, ch: usize, images: &[&[f32]], weights: &[Vec<f3
|
||||
collapse(&a, ch).into_iter().map(|v| v.clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// Focus-stack fusion settings for [`fuse_stack`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StackFusion {
|
||||
/// Region-energy window radius for the band-pass levels: 1 = the 3×3 binomial window of
|
||||
/// Wang & Chang; 0 = the per-node |L| of Adelson et al.
|
||||
pub energy_radius: usize,
|
||||
/// Halo control hardness: 0 = off (every level picks its own winner); otherwise the levels
|
||||
/// coarser than `guide` are the mean of the images weighed by the guide's region energy to
|
||||
/// this power, capped at [`HALO_MAX`]. Default 2.
|
||||
pub halo: f32,
|
||||
/// The guide level for halo control (0 = the finest band-pass level); clamped to the last
|
||||
/// band-pass level.
|
||||
pub guide: usize,
|
||||
/// Pyramid levels, the residual included; `None` = [`stack_levels`].
|
||||
pub levels: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for StackFusion {
|
||||
fn default() -> Self {
|
||||
StackFusion { energy_radius: 1, halo: 2.0, guide: 2, levels: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest useful halo hardness: a weight's exponent is clamped to ±60 (a span of 1e52), which
|
||||
/// hardness 8 uses up over the practical range of energies.
|
||||
pub const HALO_MAX: f32 = 8.0;
|
||||
/// Reference energy the halo weights are relative to (a well-textured quarter-resolution band),
|
||||
/// so that `(RE / ref)^p` stays within f32 up to [`HALO_MAX`].
|
||||
const HALO_REF: f32 = 1e-4;
|
||||
/// Floor added to the energy before weighing: below it the images average.
|
||||
const HALO_FLOOR: f32 = 1e-8;
|
||||
/// Coverage below which an image has no say at a pixel.
|
||||
const COVERED: f32 = 0.5;
|
||||
/// The energy of an image where it does not cover the pixel: below any real energy, so any
|
||||
/// covering image wins over it.
|
||||
const NO_ENERGY: f32 = -1.0;
|
||||
|
||||
/// Levels for [`fuse_stack`]: as many band-pass levels as keep the residual's short side ≥ 32
|
||||
/// px, so the residual still has the local structure its deviation / entropy rule measures.
|
||||
pub fn stack_levels(w: usize, h: usize) -> usize {
|
||||
let mut n = 1;
|
||||
let mut s = w.min(h);
|
||||
while s.div_ceil(2) >= 32 && n < 10 {
|
||||
s = s.div_ceil(2);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// The weight a coarse coefficient gets from the guide's region energy `re` at hardness `p`:
|
||||
/// `((re + floor) / ref)^p`, its exponent clamped to ±60.
|
||||
fn halo_weight(re: f32, p: f32) -> f32 {
|
||||
(p * ((re.max(0.0) + HALO_FLOOR) / HALO_REF).ln()).clamp(-60.0, 60.0).exp()
|
||||
}
|
||||
|
||||
/// Binomial window weights of radius `r` (row 2r of Pascal's triangle / 4^r).
|
||||
fn binomial(r: usize) -> Vec<f32> {
|
||||
let n = 2 * r + 1;
|
||||
let mut row = vec![1u64; n];
|
||||
for i in 1..n {
|
||||
for j in (1..i).rev() {
|
||||
row[j] += row[j - 1];
|
||||
}
|
||||
}
|
||||
let s = row.iter().sum::<u64>() as f32;
|
||||
row.into_iter().map(|v| v as f32 / s).collect()
|
||||
}
|
||||
|
||||
/// Separable weighted window sum of a plane (`wt` of odd length, clamped borders).
|
||||
fn window_sum(w: usize, h: usize, src: &[f32], wt: &[f32]) -> Vec<f32> {
|
||||
let r = wt.len() / 2;
|
||||
if r == 0 || w == 0 || h == 0 {
|
||||
return src.to_vec();
|
||||
}
|
||||
let mut tmp = vec![0.0f32; w * h];
|
||||
par_rows(&mut tmp, w, 1, |y, row| {
|
||||
let s = &src[y * w..y * w + w];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = wt.iter().enumerate().map(|(t, k)| k * s[(x + t).saturating_sub(r).min(w - 1)]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
for (t, k) in wt.iter().enumerate() {
|
||||
let sy = (y + t).saturating_sub(r).min(h - 1);
|
||||
for (o, v) in row.iter_mut().zip(&tmp[sy * w..sy * w + w]) {
|
||||
*o += k * v;
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Region energy of a band-pass luminance level: the window sum of L² (Wang & Chang eq. 13);
|
||||
/// `NO_ENERGY` where the image's coverage `cover` is below [`COVERED`].
|
||||
fn region_energy(l: &Level, cover: &Level, win: &[f32]) -> Vec<f32> {
|
||||
let e: Vec<f32> = l.px.iter().map(|v| v * v).collect();
|
||||
let mut re = window_sum(l.w, l.h, &e, win);
|
||||
for (r, a) in re.iter_mut().zip(&cover.px) {
|
||||
if *a < COVERED {
|
||||
*r = NO_ENERGY;
|
||||
}
|
||||
}
|
||||
re
|
||||
}
|
||||
|
||||
/// Local deviation (Wang & Chang eq. 10) and local entropy (eq. 11, 256 gray levels) of a
|
||||
/// plane over a 5×5 window, clamped borders.
|
||||
fn deviation_entropy(w: usize, h: usize, y: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
const R: usize = 2;
|
||||
const BINS: usize = 256;
|
||||
let n = (2 * R + 1) * (2 * R + 1);
|
||||
let inv_n = 1.0 / n as f32;
|
||||
let (mut dev, mut ent) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
|
||||
if w == 0 || h == 0 {
|
||||
return (dev, ent);
|
||||
}
|
||||
par_rows2(&mut dev, &mut ent, w, |i, drow, erow| {
|
||||
let mut hist = [0u32; BINS];
|
||||
let mut touched: Vec<usize> = Vec::with_capacity(n);
|
||||
for j in 0..w {
|
||||
let (mut s, mut s2) = (0.0f32, 0.0f32);
|
||||
touched.clear();
|
||||
for dy in 0..=2 * R {
|
||||
let yy = (i + dy).saturating_sub(R).min(h - 1);
|
||||
for dx in 0..=2 * R {
|
||||
let v = y[yy * w + (j + dx).saturating_sub(R).min(w - 1)];
|
||||
s += v;
|
||||
s2 += v * v;
|
||||
let q = ((v.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize).min(BINS - 1);
|
||||
if hist[q] == 0 {
|
||||
touched.push(q);
|
||||
}
|
||||
hist[q] += 1;
|
||||
}
|
||||
}
|
||||
let mean = s * inv_n;
|
||||
drow[j] = (s2 * inv_n - mean * mean).max(0.0);
|
||||
let mut e = 0.0f32;
|
||||
for &q in &touched {
|
||||
let p = hist[q] as f32 * inv_n;
|
||||
e -= p * p.ln();
|
||||
hist[q] = 0;
|
||||
}
|
||||
erow[j] = e;
|
||||
}
|
||||
});
|
||||
(dev, ent)
|
||||
}
|
||||
|
||||
/// One image's residual (low-pass) level, with its luminance and coverage at that level.
|
||||
struct Residual {
|
||||
px: Level,
|
||||
luma: Vec<f32>,
|
||||
cover: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Fuse the images' residuals by Wang & Chang's region-information rule (eq. 12), generalised
|
||||
/// to N images as a Pareto rule: an image is dominated where another covering image is at least
|
||||
/// as good on both local deviation and local entropy and strictly better on one; the fused value
|
||||
/// is the mean of the non-dominated images. Images that do not cover the pixel are left out
|
||||
/// unless none does.
|
||||
fn fuse_residuals(tops: &[Residual], ch: usize) -> Level {
|
||||
let Some(t0) = tops.first() else { return Level { w: 0, h: 0, px: Vec::new() } };
|
||||
let (w, h, n) = (t0.px.w, t0.px.h, tops.len());
|
||||
let measures: Vec<(Vec<f32>, Vec<f32>)> = par_map(n, |k| deviation_entropy(w, h, &tops[k].luma));
|
||||
let mut out = vec![0.0f32; w * h * ch];
|
||||
let mut keep = vec![false; n];
|
||||
for i in 0..w * h {
|
||||
let any_cover = tops.iter().any(|t| t.cover[i] >= COVERED);
|
||||
let eligible = |a: usize| !any_cover || tops[a].cover[i] >= COVERED;
|
||||
let mut kept = 0usize;
|
||||
for a in 0..n {
|
||||
let (da, ea) = (measures[a].0[i], measures[a].1[i]);
|
||||
let dominated = eligible(a)
|
||||
&& (0..n).any(|b| {
|
||||
let (db, eb) = (measures[b].0[i], measures[b].1[i]);
|
||||
b != a && eligible(b) && db >= da && eb >= ea && (db > da || eb > ea)
|
||||
});
|
||||
keep[a] = eligible(a) && !dominated;
|
||||
kept += keep[a] as usize;
|
||||
}
|
||||
let inv = 1.0 / kept.max(1) as f32;
|
||||
for (t, _) in tops.iter().zip(&keep).filter(|(_, k)| **k) {
|
||||
for c in 0..ch {
|
||||
out[i * ch + c] += t.px.px[i * ch + c] * inv;
|
||||
}
|
||||
}
|
||||
}
|
||||
Level { w, h, px: out }
|
||||
}
|
||||
|
||||
/// Halo control: fold the levels coarser than the guide `g` as `Σ w·L` and `Σ w`, the guide's
|
||||
/// weights `wgt` REDUCEd to each level's size. `src` is the image's pyramid; `None` scales the
|
||||
/// first image's levels, already in `acc`, in place.
|
||||
fn fold_halo(acc: &mut [Level], wsum: &mut Vec<Vec<f32>>, src: Option<&[Level]>, g: usize, mut wgt: Level, ch: usize) {
|
||||
for li in g + 1..acc.len() {
|
||||
wgt = reduce(&wgt, 1);
|
||||
let a = &mut acc[li];
|
||||
let (aw, wp) = (a.w, &wgt.px);
|
||||
match src {
|
||||
Some(src) => {
|
||||
if let Some(s) = wsum.get_mut(li - g - 1) {
|
||||
for (s, k) in s.iter_mut().zip(wp) {
|
||||
*s += k;
|
||||
}
|
||||
}
|
||||
let sp = &src[li].px;
|
||||
par_rows(&mut a.px, aw, ch, |y, row| {
|
||||
for (x, px) in row.chunks_mut(ch).enumerate() {
|
||||
let (i, k) = (y * aw + x, wp[y * aw + x]);
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v += k * sp[i * ch + c];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
None => {
|
||||
par_rows(&mut a.px, aw, ch, |y, row| {
|
||||
for (x, px) in row.chunks_mut(ch).enumerate() {
|
||||
let k = wp[y * aw + x];
|
||||
px.iter_mut().for_each(|v| *v *= k);
|
||||
}
|
||||
});
|
||||
wsum.push(wp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multifocus composite of `images` (interleaved `w × h × ch`; `luma[k]` is image k's luminance,
|
||||
/// `alpha[k]` its coverage): per Laplacian-pyramid coefficient, the image whose luminance band
|
||||
/// has the largest region energy wins (Adelson et al. 1984's multifocus composite with the
|
||||
/// maximum-region-energy rule of Wang & Chang 2011), the residual follows the deviation +
|
||||
/// entropy rule, and the blending between images happens in the reconstruction itself, so every
|
||||
/// scale is taken from the image that is sharp at that scale. Ties keep the earlier image, and an
|
||||
/// image has no say where its coverage is below one half.
|
||||
///
|
||||
/// With halo control (`p.halo` > 0) the levels coarser than `p.guide` do not pick winners:
|
||||
/// beside a bright object, the images focused behind it carry the object's defocused copy spread
|
||||
/// over the background, strong coarse energy where the image that has the object sharp has
|
||||
/// none, so a per-level pick collects that glow. Instead those levels are the mean of the images
|
||||
/// weighed by the guide's region energy to the power `p.halo` (REDUCEd to each level's size, as
|
||||
/// a multiresolution spline blends with a weight mask), so the coarse structure follows the images
|
||||
/// the guide found sharp, and where none is the images average.
|
||||
///
|
||||
/// Images are folded in one at a time: only the running fused pyramid, one best-energy (or
|
||||
/// weight-sum) plane per level and the small residuals are held. Output is clamped to 0..=1.
|
||||
pub fn fuse_stack(w: usize, h: usize, ch: usize, images: &[&[f32]], luma: &[Vec<f32>], alpha: &[Vec<f32>], p: StackFusion) -> Vec<f32> {
|
||||
// Levels the pyramid can actually have (REDUCE stops at 1 × 1).
|
||||
let levels = {
|
||||
let want = p.levels.unwrap_or_else(|| stack_levels(w, h)).max(1);
|
||||
let (mut n, mut a, mut b) = (1, w, h);
|
||||
while (a > 1 || b > 1) && n < want {
|
||||
(a, b, n) = (a.div_ceil(2), b.div_ceil(2), n + 1);
|
||||
}
|
||||
n
|
||||
};
|
||||
let bands = levels - 1;
|
||||
let halo = (bands > 0 && p.halo > 0.0).then(|| (p.guide.min(bands - 1), p.halo.min(HALO_MAX)));
|
||||
// The band-pass levels that pick their own winner: all of them, or up to the guide.
|
||||
let nsel = halo.map_or(bands, |(g, _)| g + 1);
|
||||
let win = binomial(p.energy_radius);
|
||||
let mut acc: Vec<Level> = Vec::new();
|
||||
// Per selecting level, the winning energy so far.
|
||||
let mut best: Vec<Vec<f32>> = Vec::new();
|
||||
// With halo control, per level coarser than the guide, the weight sum so far.
|
||||
let mut wsum: Vec<Vec<f32>> = Vec::new();
|
||||
let mut tops: Vec<Residual> = Vec::new();
|
||||
for ((img, lm), al) in images.iter().zip(luma).zip(alpha) {
|
||||
let lp = laplacian(w, h, ch, img, levels);
|
||||
let ll = laplacian(w, h, 1, lm, levels);
|
||||
let ga = gaussian(w, h, 1, al, levels);
|
||||
let (Some(top), Some(tl), Some(ta)) = (lp.last(), ll.last(), ga.last()) else { continue };
|
||||
if lp.len() != levels || ll.len() != levels || ga.len() != levels {
|
||||
continue;
|
||||
}
|
||||
if halo.is_none() {
|
||||
tops.push(Residual { px: top.clone(), luma: tl.px.clone(), cover: ta.px.clone() });
|
||||
}
|
||||
let energies: Vec<Vec<f32>> = (0..nsel).map(|li| region_energy(&ll[li], &ga[li], &win)).collect();
|
||||
let wgt =
|
||||
halo.map(|(g, hard)| Level { w: ll[g].w, h: ll[g].h, px: energies[g].iter().map(|&e| if e < 0.0 { 0.0 } else { halo_weight(e, hard) }).collect() });
|
||||
if acc.is_empty() {
|
||||
acc = lp;
|
||||
best = energies;
|
||||
if let (Some((g, _)), Some(wgt)) = (halo, wgt) {
|
||||
fold_halo(&mut acc, &mut wsum, None, g, wgt, ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for li in 0..nsel {
|
||||
let (lvl, lw) = (&lp[li], lp[li].w);
|
||||
let take: Vec<bool> = energies[li]
|
||||
.iter()
|
||||
.zip(best[li].iter_mut())
|
||||
.map(|(e, b)| {
|
||||
if *e > *b {
|
||||
*b = *e;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
par_rows(&mut acc[li].px, lw, ch, |y, row| {
|
||||
for (x, px) in row.chunks_mut(ch).enumerate() {
|
||||
if take[y * lw + x] {
|
||||
px.copy_from_slice(&lvl.px[(y * lw + x) * ch..(y * lw + x + 1) * ch]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let (Some((g, _)), Some(wgt)) = (halo, wgt) {
|
||||
fold_halo(&mut acc, &mut wsum, Some(&lp), g, wgt, ch);
|
||||
}
|
||||
}
|
||||
if acc.is_empty() {
|
||||
return vec![0.0; w * h * ch];
|
||||
}
|
||||
match halo {
|
||||
Some((g, _)) => {
|
||||
// The weighted means: Σ w·L / Σ w.
|
||||
for li in g + 1..levels {
|
||||
let (a, s) = (&mut acc[li], &wsum[li - g - 1]);
|
||||
let aw = a.w;
|
||||
par_rows(&mut a.px, aw, ch, |y, row| {
|
||||
for (x, px) in row.chunks_mut(ch).enumerate() {
|
||||
let k = s[y * aw + x];
|
||||
px.iter_mut().for_each(|v| *v = if k > 0.0 { *v / k } else { 0.0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(last) = acc.last_mut() {
|
||||
*last = fuse_residuals(&tops, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
collapse(&acc, ch).into_iter().map(|v| v.clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
fn box_blur1(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
|
||||
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
|
||||
let mut dst = vec![0.0f32; src.len()];
|
||||
@@ -313,4 +665,221 @@ mod tests {
|
||||
let seam = (0..w).find(|x| wts[1][10 * w + x] == 1.0).unwrap();
|
||||
assert!((25..=35).contains(&seam), "{seam}");
|
||||
}
|
||||
|
||||
fn checker_img(w: usize, h: usize, cell: usize, ch: usize) -> Vec<f32> {
|
||||
let mut px = vec![0.0f32; w * h * ch];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = if ((x / cell) + (y / cell)).is_multiple_of(2) { 0.2 } else { 0.8 };
|
||||
let p = &mut px[(y * w + x) * ch..(y * w + x + 1) * ch];
|
||||
for (c, o) in p.iter_mut().enumerate() {
|
||||
*o = match c {
|
||||
0 => v,
|
||||
1 => v * 0.9,
|
||||
2 => v * 0.5,
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn luma_of(w: usize, h: usize, ch: usize, px: &[f32]) -> Vec<f32> {
|
||||
(0..w * h).map(|i| if ch >= 3 { 0.2126 * px[i * ch] + 0.7152 * px[i * ch + 1] + 0.0722 * px[i * ch + 2] } else { px[i * ch] }).collect()
|
||||
}
|
||||
|
||||
/// Blur every channel with the 5-tap kernel `passes` times (no decimation).
|
||||
fn blur_img(w: usize, h: usize, ch: usize, px: &[f32], passes: usize) -> Vec<f32> {
|
||||
let mut planes: Vec<Vec<f32>> = (0..ch).map(|c| (0..w * h).map(|i| px[i * ch + c]).collect()).collect();
|
||||
for _ in 0..passes {
|
||||
for p in planes.iter_mut() {
|
||||
*p = window_sum(w, h, p, &K);
|
||||
}
|
||||
}
|
||||
(0..w * h * ch).map(|i| planes[i % ch][i / ch]).collect()
|
||||
}
|
||||
|
||||
fn rms(a: &[f32], b: &[f32]) -> f32 {
|
||||
(a.iter().zip(b).map(|(x, y)| ((x - y) * (x - y)) as f64).sum::<f64>() / a.len() as f64).sqrt() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_levels_keep_the_residual_above_32_px() {
|
||||
assert_eq!(stack_levels(64, 64), 2);
|
||||
assert_eq!(stack_levels(48, 100), 1);
|
||||
assert_eq!(stack_levels(2070, 1378), 6);
|
||||
assert_eq!(stack_levels(8280, 5520), 8);
|
||||
assert_eq!(stack_levels(100_000, 100_000), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binomial_window_and_window_sum() {
|
||||
assert_eq!(binomial(0), vec![1.0]);
|
||||
assert_eq!(binomial(1), vec![0.25, 0.5, 0.25]);
|
||||
assert!(binomial(2).iter().zip(&K).all(|(a, k)| (a - k).abs() < 1e-6));
|
||||
let flat = vec![0.3f32; 7 * 5];
|
||||
assert!(window_sum(7, 5, &flat, &binomial(1)).iter().all(|v| (v - 0.3).abs() < 1e-6), "a window sum of unit weight keeps a flat plane");
|
||||
assert_eq!(window_sum(7, 5, &flat, &binomial(0)), flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deviation_and_entropy_measure_local_structure() {
|
||||
let (w, h) = (20, 20);
|
||||
let flat = vec![0.5f32; w * h];
|
||||
let (d, e) = deviation_entropy(w, h, &flat);
|
||||
assert!(d.iter().all(|v| *v == 0.0) && e.iter().all(|v| *v == 0.0));
|
||||
let check = luma_of(w, h, 1, &checker_img(w, h, 1, 1));
|
||||
let (d, e) = deviation_entropy(w, h, &check);
|
||||
assert!(d[10 * w + 10] > 0.08 && e[10 * w + 10] > 0.6, "{} {}", d[10 * w + 10], e[10 * w + 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_stack_identical_images_are_a_fixed_point() {
|
||||
let (w, h, ch) = (70, 50, 3);
|
||||
let im = checker_img(w, h, 7, ch);
|
||||
let lm = luma_of(w, h, ch, &im);
|
||||
let ones = vec![1.0f32; w * h];
|
||||
let three = StackFusion { levels: Some(3), ..Default::default() };
|
||||
for p in [three, StackFusion { halo: 0.0, ..three }, StackFusion { guide: 1, ..three }, StackFusion { energy_radius: 0, ..three }] {
|
||||
let out = fuse_stack(w, h, ch, &[&im, &im, &im], &[lm.clone(), lm.clone(), lm.clone()], &[ones.clone(), ones.clone(), ones.clone()], p);
|
||||
assert!(rms(&out, &im) < 1e-5, "{p:?}: {}", rms(&out, &im));
|
||||
}
|
||||
// One image, and none, are fine too.
|
||||
assert!(rms(&fuse_stack(w, h, ch, &[&im], std::slice::from_ref(&lm), std::slice::from_ref(&ones), three), &im) < 1e-5);
|
||||
assert_eq!(fuse_stack(w, h, ch, &[], &[], &[], three), vec![0.0; w * h * ch]);
|
||||
// More levels than the image has are clamped.
|
||||
let many = StackFusion { levels: Some(40), ..Default::default() };
|
||||
assert!(rms(&fuse_stack(w, h, ch, &[&im], &[lm], &[ones], many), &im) < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_stack_picks_the_sharp_half_from_each_image() {
|
||||
let (w, h, ch) = (96, 64, 4);
|
||||
let sharp = checker_img(w, h, 6, ch);
|
||||
let soft = blur_img(w, h, ch, &sharp, 6);
|
||||
// Image A is sharp on the left and blurred on the right, image B the opposite.
|
||||
let (mut a, mut b) = (sharp.clone(), sharp.clone());
|
||||
for i in 0..w * h {
|
||||
for c in 0..ch {
|
||||
if i % w >= w / 2 {
|
||||
a[i * ch + c] = soft[i * ch + c];
|
||||
} else {
|
||||
b[i * ch + c] = soft[i * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
let (la, lb) = (luma_of(w, h, ch, &a), luma_of(w, h, ch, &b));
|
||||
let ones = vec![1.0f32; w * h];
|
||||
for p in [
|
||||
StackFusion { halo: 0.0, ..Default::default() },
|
||||
StackFusion { halo: 0.0, energy_radius: 0, ..Default::default() },
|
||||
StackFusion { guide: 0, ..Default::default() },
|
||||
] {
|
||||
let out = fuse_stack(w, h, ch, &[&a, &b], &[la.clone(), lb.clone()], &[ones.clone(), ones.clone()], p);
|
||||
let err = rms(&out, &sharp);
|
||||
assert!(err < 0.3 * rms(&a, &sharp), "{p:?}: fused rms {err}");
|
||||
assert!(out.iter().skip(3).step_by(ch).all(|a| (a - 1.0).abs() < 1e-4), "alpha stays opaque");
|
||||
// Away from the seam each side is the sharp image. Without halo control the residual
|
||||
// (one REDUCE here) averages where the blurred side's entropy beats the checker's, so
|
||||
// the finer scales are exact and the whole is within a few percent; with halo control
|
||||
// the guide's energy weighs the residual too.
|
||||
let (e1, e2) = ((out[(20 * w + 10) * ch] - sharp[(20 * w + 10) * ch]).abs(), (out[(20 * w + 85) * ch + 1] - sharp[(20 * w + 85) * ch + 1]).abs());
|
||||
let tol = if p.halo > 0.0 { 1e-3 } else { 0.1 };
|
||||
assert!(e1 < tol && e2 < tol, "{p:?}: {e1} {e2}");
|
||||
}
|
||||
// The old full-resolution weights and spline blend do worse on the same pair.
|
||||
let wts = stack_weights(w, h, &[la, lb], &[ones.clone(), ones]);
|
||||
let old = blend(w, h, ch, &[&a, &b], &wts, auto_levels(w, h));
|
||||
let new =
|
||||
fuse_stack(w, h, ch, &[&a, &b], &[luma_of(w, h, ch, &a), luma_of(w, h, ch, &b)], &[vec![1.0; w * h], vec![1.0; w * h]], StackFusion::default());
|
||||
assert!(rms(&new, &sharp) <= rms(&old, &sharp), "new {} old {}", rms(&new, &sharp), rms(&old, &sharp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_stack_ignores_uncovered_pixels() {
|
||||
let (w, h, ch) = (96, 64, 4);
|
||||
let sharp = checker_img(w, h, 6, ch);
|
||||
// Image A: flat gray everywhere. Image B: the sharp checker on the left half, garbage
|
||||
// (opaque white in the colour channels, alpha 0) on the right.
|
||||
let a: Vec<f32> = (0..w * h * ch).map(|i| if i % ch == 3 { 1.0 } else { 0.5 }).collect();
|
||||
let b: Vec<f32> = (0..w * h * ch)
|
||||
.map(|i| {
|
||||
if (i / ch) % w < w / 2 {
|
||||
sharp[i]
|
||||
} else if i % ch == 3 {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let alpha_b: Vec<f32> = (0..w * h).map(|i| if i % w < w / 2 { 1.0 } else { 0.0 }).collect();
|
||||
let (la, lb) = (luma_of(w, h, ch, &a), luma_of(w, h, ch, &b));
|
||||
for p in [StackFusion { halo: 0.0, ..Default::default() }, StackFusion { guide: 0, ..Default::default() }] {
|
||||
let out = fuse_stack(w, h, ch, &[&a, &b], &[la.clone(), lb.clone()], &[vec![1.0; w * h], alpha_b.clone()], p);
|
||||
// Left: the checker; right: A's gray, not B's white.
|
||||
assert!((out[(30 * w + 12) * ch] - sharp[(30 * w + 12) * ch]).abs() < 0.05, "{p:?}");
|
||||
for x in [60, 70, 90] {
|
||||
let px = &out[(30 * w + x) * ch..(30 * w + x + 1) * ch];
|
||||
assert!(px[..3].iter().all(|v| (v - 0.5).abs() < 0.03), "{p:?}: x {x} {px:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The halo mechanism in one piece: image A is sharp fine texture; image B is the texture
|
||||
/// defocused with a broad bright bump on it (coarse structure only, as a defocused copy of a
|
||||
/// bright object has). Every level picking its own winner takes the bump, since B alone has
|
||||
/// energy at the coarse levels; with halo control the guide finds A sharp everywhere, so the
|
||||
/// coarse levels follow A and the bump stays out.
|
||||
#[test]
|
||||
fn halo_control_keeps_the_coarse_levels_with_the_guide() {
|
||||
let (w, h, ch) = (192, 144, 3);
|
||||
let a = checker_img(w, h, 6, ch); // a 12 px period: the guide (level 2, 4 px) sees it
|
||||
let mut b = blur_img(w, h, ch, &a, 64); // σ ≈ 8 px: the texture is gone
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d2 = (x as f32 - 96.0).powi(2) + (y as f32 - 72.0).powi(2);
|
||||
let bump = 0.35 * (-d2 / (2.0 * 20.0f32.powi(2))).exp();
|
||||
for c in 0..ch {
|
||||
let v = &mut b[(y * w + x) * ch + c];
|
||||
*v = (*v + bump).min(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ones = vec![1.0f32; w * h];
|
||||
let fuse = |halo: f32, imgs: [&[f32]; 2]| {
|
||||
let l = [luma_of(w, h, ch, imgs[0]), luma_of(w, h, ch, imgs[1])];
|
||||
fuse_stack(w, h, ch, &imgs, &l, &[ones.clone(), ones.clone()], StackFusion { halo, guide: 2, levels: Some(5), ..Default::default() })
|
||||
};
|
||||
let off = rms(&fuse(0.0, [&a, &b]), &a);
|
||||
assert!(off > 0.05, "without halo control the bump gets in: rms {off}");
|
||||
for halo in [1.0, 2.0, 4.0, 8.0, 100.0] {
|
||||
let on = rms(&fuse(halo, [&a, &b]), &a);
|
||||
assert!(on < 0.15 * off, "halo {halo}: rms {on} vs {off} without");
|
||||
}
|
||||
// The order of the images makes no difference.
|
||||
assert!(rms(&fuse(2.0, [&b, &a]), &a) < 0.15 * off);
|
||||
assert!((halo_weight(HALO_REF - HALO_FLOOR, 3.0) - 1.0).abs() < 1e-5);
|
||||
assert!(halo_weight(1e-3, 2.0) > halo_weight(1e-4, 2.0));
|
||||
assert!(halo_weight(0.0, 8.0) > 0.0 && halo_weight(1e3, 8.0).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn residual_rule_takes_the_image_that_wins_deviation_and_entropy() {
|
||||
// With a single level the whole image is the residual: the checker (2 px cells, so every
|
||||
// 5×5 window, the clamped corner ones included, straddles an edge) wins both measures over
|
||||
// the flat image everywhere, so the fused result is the checker.
|
||||
let (w, h, ch) = (40, 40, 3);
|
||||
let a = checker_img(w, h, 2, ch);
|
||||
let b = vec![0.5f32; w * h * ch];
|
||||
let ones = vec![1.0f32; w * h];
|
||||
let one = StackFusion { levels: Some(1), halo: 0.0, ..Default::default() };
|
||||
let out = fuse_stack(w, h, ch, &[&b, &a], &[luma_of(w, h, ch, &b), luma_of(w, h, ch, &a)], &[ones.clone(), ones.clone()], one);
|
||||
assert!(rms(&out, &a) < 1e-6);
|
||||
// Identical measures average (the Pareto rule keeps both).
|
||||
let out = fuse_stack(w, h, ch, &[&b, &a], &[luma_of(w, h, ch, &a), luma_of(w, h, ch, &a)], &[ones.clone(), ones], one);
|
||||
let mean: Vec<f32> = a.iter().zip(&b).map(|(x, y)| (x + y) / 2.0).collect();
|
||||
assert!(rms(&out, &mean) < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::image::{premultiply, unpremultiply};
|
||||
|
||||
/// Resampling method (Photoshop's Image Size options).
|
||||
/// Resampling method (Photoshop's Image Size options, and Lanczos).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Resample {
|
||||
@@ -14,51 +14,121 @@ pub enum Resample {
|
||||
Bilinear,
|
||||
#[default]
|
||||
Bicubic,
|
||||
/// Photoshop's Bicubic Smoother, for enlargement.
|
||||
BicubicSmoother,
|
||||
/// Photoshop's Bicubic Sharper, for reduction.
|
||||
BicubicSharper,
|
||||
/// Photoshop's Bicubic Automatic: Smoother where the image grows, Sharper where it shrinks.
|
||||
BicubicAutomatic,
|
||||
Lanczos,
|
||||
/// Lanczos followed by a light sharpening when enlarging.
|
||||
/// Lanczos followed by a light sharpening when enlarging; Bicubic Sharper when reducing, as
|
||||
/// Photoshop's Preserve Details reduces.
|
||||
PreserveDetails,
|
||||
}
|
||||
|
||||
impl Resample {
|
||||
fn support(self) -> f64 {
|
||||
match self {
|
||||
Resample::Nearest => 0.5,
|
||||
Resample::Bilinear => 1.0,
|
||||
Resample::Bicubic => 2.0,
|
||||
Resample::Lanczos | Resample::PreserveDetails => 3.0,
|
||||
}
|
||||
/// Sub-pixel positions Photoshop's filters resolve: a sample's offset is rounded to 1/128 pixel.
|
||||
const PHASES: f64 = 128.0;
|
||||
|
||||
/// The filter of one axis.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kernel {
|
||||
Nearest,
|
||||
/// Linear interpolation, never widened. Below half size, each output pixel is the plain
|
||||
/// average of the source pixels it covers.
|
||||
Bilinear,
|
||||
/// The Keys cubic with parameter `a`, stretched over `stretch` source pixels.
|
||||
Keys {
|
||||
a: f64,
|
||||
stretch: f64,
|
||||
},
|
||||
/// Lanczos-3, widened by 1/scale when reducing.
|
||||
Lanczos,
|
||||
}
|
||||
|
||||
/// Photoshop's bicubic filters, measured on Photoshop 25.4 (the Keys cubic, phase table and
|
||||
/// stretch fitted to within 1/4000 of every weight). `s` is the scale the filter is designed for.
|
||||
///
|
||||
/// - Bicubic: a = −0.75.
|
||||
/// - Bicubic Smoother: stretched by 1.15; a = −0.625, and −0.625 − 0.5·(1 − s) when reducing.
|
||||
/// - Bicubic Sharper: stretched by 1.05; a = −1, and −1 − 1.6·(1 − s) when reducing.
|
||||
///
|
||||
/// Reducing widens the filter by 1/s, down to ¼; below that [`taps`] first averages the image
|
||||
/// down to four times the target.
|
||||
fn keys(method: Resample, s: f64) -> Kernel {
|
||||
let s = s.clamp(0.25, 1.0);
|
||||
let (a, stretch) = match method {
|
||||
Resample::BicubicSmoother => (-0.625 - 0.5 * (1.0 - s), 1.15),
|
||||
Resample::BicubicSharper => (-1.0 - 1.6 * (1.0 - s), 1.05),
|
||||
_ => (-0.75, 1.0),
|
||||
};
|
||||
Kernel::Keys { a, stretch: stretch / s }
|
||||
}
|
||||
|
||||
fn keys_weight(x: f64, a: f64) -> f64 {
|
||||
let x = x.abs();
|
||||
if x < 1.0 {
|
||||
(a + 2.0) * x * x * x - (a + 3.0) * x * x + 1.0
|
||||
} else if x < 2.0 {
|
||||
a * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
fn weight(self, x: f64) -> f64 {
|
||||
let x = x.abs();
|
||||
match self {
|
||||
Resample::Nearest => {
|
||||
if x <= 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn lanczos_weight(x: f64) -> f64 {
|
||||
let x = x.abs();
|
||||
if x < 1e-9 {
|
||||
1.0
|
||||
} else if x < 3.0 {
|
||||
let px = std::f64::consts::PI * x;
|
||||
3.0 * px.sin() * (px / 3.0).sin() / (px * px)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// One resampling pass: scale factors and the filter of each axis.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Pass {
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
kx: Kernel,
|
||||
ky: Kernel,
|
||||
}
|
||||
|
||||
/// Photoshop's passes for `filter` at `(sx, sy)`.
|
||||
///
|
||||
/// A bicubic filter is designed for the height's scale, or the width's when the height stays,
|
||||
/// and serves both axes; the axis with the smaller scale goes first, and the image is clipped and
|
||||
/// stored between the passes. Bicubic Automatic with one axis growing and the other shrinking is
|
||||
/// two Image Sizes: the shrinking axis with Sharper, then the growing one with Smoother, each
|
||||
/// designed for its own scale. Nearest, Bilinear and Lanczos run both axes in one pass.
|
||||
fn plan(filter: Resample, sx: f64, sy: f64) -> Vec<Pass> {
|
||||
let both = |k: Kernel| vec![Pass { sx, sy, kx: k, ky: k }];
|
||||
match filter {
|
||||
Resample::Nearest => both(Kernel::Nearest),
|
||||
Resample::Bilinear => both(Kernel::Bilinear),
|
||||
Resample::Lanczos => both(Kernel::Lanczos),
|
||||
Resample::PreserveDetails if sx > 1.0 || sy > 1.0 => both(Kernel::Lanczos),
|
||||
Resample::PreserveDetails => plan(Resample::BicubicSharper, sx, sy),
|
||||
Resample::BicubicAutomatic if sx <= 1.0 && sy <= 1.0 => plan(Resample::BicubicSharper, sx, sy),
|
||||
Resample::BicubicAutomatic if sx >= 1.0 && sy >= 1.0 => plan(Resample::BicubicSmoother, sx, sy),
|
||||
Resample::BicubicAutomatic => {
|
||||
let (sharper, smoother) = (Resample::BicubicSharper, Resample::BicubicSmoother);
|
||||
if sx < 1.0 {
|
||||
vec![Pass { sx, sy: 1.0, kx: keys(sharper, sx), ky: Kernel::Nearest }, Pass { sx: 1.0, sy, kx: Kernel::Nearest, ky: keys(smoother, sy) }]
|
||||
} else {
|
||||
vec![Pass { sx: 1.0, sy, kx: Kernel::Nearest, ky: keys(sharper, sy) }, Pass { sx, sy: 1.0, kx: keys(smoother, sx), ky: Kernel::Nearest }]
|
||||
}
|
||||
Resample::Bilinear => (1.0 - x).max(0.0),
|
||||
Resample::Bicubic => {
|
||||
// Keys cubic, a = -0.5 (Catmull-Rom).
|
||||
let a = -0.5;
|
||||
if x < 1.0 {
|
||||
(a + 2.0) * x * x * x - (a + 3.0) * x * x + 1.0
|
||||
} else if x < 2.0 {
|
||||
a * x * x * x - 5.0 * a * x * x + 8.0 * a * x - 4.0 * a
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
Resample::Lanczos | Resample::PreserveDetails => {
|
||||
if x < 1e-9 {
|
||||
1.0
|
||||
} else if x < 3.0 {
|
||||
let px = std::f64::consts::PI * x;
|
||||
3.0 * px.sin() * (px / 3.0).sin() / (px * px)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
Resample::Bicubic | Resample::BicubicSmoother | Resample::BicubicSharper => {
|
||||
let k = keys(filter, if sy != 1.0 { sy } else { sx });
|
||||
if sx == 1.0 || sy == 1.0 {
|
||||
both(k)
|
||||
} else if sy < sx {
|
||||
vec![Pass { sx: 1.0, sy, kx: k, ky: k }, Pass { sx, sy: 1.0, kx: k, ky: k }]
|
||||
} else {
|
||||
vec![Pass { sx, sy: 1.0, kx: k, ky: k }, Pass { sx: 1.0, sy, kx: k, ky: k }]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,36 +142,91 @@ struct Taps {
|
||||
outside: f32,
|
||||
}
|
||||
|
||||
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
|
||||
/// via `u = (o + 0.5) / scale - 0.5`. With `edge` (`[first, last]` source index), taps outside it
|
||||
/// read the nearest sample inside it: the image repeats its edge instead of fading out.
|
||||
/// The source position of output sample `o`, `(o + 0.5) / scale − 0.5`, kept on the edge
|
||||
/// samples (`[first, last]`) when there is an edge, and rounded to Photoshop's 1/128 pixel.
|
||||
fn position(o: i32, scale: f64, edge: Option<(i32, i32)>) -> f64 {
|
||||
let mut u = (o as f64 + 0.5) / scale - 0.5;
|
||||
if let Some((a, b)) = edge {
|
||||
u = u.clamp(a as f64, b.max(a) as f64);
|
||||
}
|
||||
let j = u.floor();
|
||||
j + ((u - j) * PHASES).round() / PHASES
|
||||
}
|
||||
|
||||
/// The source pixels `[floor(o / scale), floor((o + 1) / scale))` output pixel `o` covers.
|
||||
fn cover(o: i32, scale: f64) -> (i32, i32) {
|
||||
let a = (o as f64 / scale + 1e-9).floor() as i32;
|
||||
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
|
||||
}
|
||||
|
||||
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
|
||||
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
|
||||
match kernel {
|
||||
Kernel::Nearest => {
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
((u + 0.5).floor() as i32, vec![1.0])
|
||||
}
|
||||
Kernel::Bilinear if scale < 0.5 => {
|
||||
let (a, b) = cover(o, scale);
|
||||
(a, vec![1.0 / (b - a) as f64; (b - a) as usize])
|
||||
}
|
||||
Kernel::Bilinear => {
|
||||
let u = position(o, scale, edge);
|
||||
let j = u.floor();
|
||||
(j as i32, vec![1.0 - (u - j), u - j])
|
||||
}
|
||||
Kernel::Keys { a, stretch } if scale < 0.25 => {
|
||||
// Averaged down to four times the target first (pixels of `inner`), then filtered.
|
||||
let inner = 4.0 * scale;
|
||||
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
|
||||
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge);
|
||||
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
|
||||
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
|
||||
let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0);
|
||||
let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
|
||||
let mut w = vec![0.0; (hi - lo).max(0) as usize];
|
||||
for ((a, b), v) in boxes.into_iter().zip(kw) {
|
||||
for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
|
||||
*slot += v / (b - a) as f64;
|
||||
}
|
||||
}
|
||||
(lo, w)
|
||||
}
|
||||
Kernel::Keys { a, stretch } => {
|
||||
let u = position(o, scale, edge);
|
||||
let lo = (u - 2.0 * stretch).floor() as i32;
|
||||
let hi = (u + 2.0 * stretch).ceil() as i32;
|
||||
let w: Vec<f64> = (lo..=hi).map(|i| keys_weight((u - i as f64) / stretch, a)).collect();
|
||||
let sum: f64 = w.iter().sum();
|
||||
(lo, w.into_iter().map(|v| v / sum).collect())
|
||||
}
|
||||
Kernel::Lanczos => {
|
||||
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
let u = edge.map_or(u, |(a, b)| u.clamp(a as f64, b.max(a) as f64));
|
||||
let lo = (u - 3.0 * stretch).ceil() as i32;
|
||||
let hi = (u + 3.0 * stretch).floor() as i32;
|
||||
let w: Vec<f64> = (lo..=hi).map(|i| lanczos_weight((i as f64 - u) / stretch)).collect();
|
||||
let sum: f64 = w.iter().sum();
|
||||
let k = if sum.abs() > 1e-12 { 1.0 / sum } else { 1.0 };
|
||||
(lo, w.into_iter().map(|v| v * k).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source at `scale`
|
||||
/// (a scale of 1 copies). With `edge` (`[first, last]` source index), taps outside it read the
|
||||
/// nearest sample inside it: the image repeats its edge instead of fading out.
|
||||
/// Taps outside `content` (`[first, last]`) all read the default pixel, so they are folded into
|
||||
/// `outside` instead of being read: the read window never grows past the content, however small
|
||||
/// the scale (#713).
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>, content: (i32, i32)) -> Vec<Taps> {
|
||||
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
|
||||
let support = filter.support() * stretch;
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> Vec<Taps> {
|
||||
let clamp = |i: i32| edge.map_or(i, |(a, b)| i.clamp(a, b));
|
||||
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
|
||||
(dst0..dst1)
|
||||
.map(|o| {
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
if filter == Resample::Nearest {
|
||||
let i = clamp((u + 0.5).floor() as i32);
|
||||
return if (content.0..=content.1).contains(&i) {
|
||||
Taps { start: i, weights: vec![1.0], outside: 0.0 }
|
||||
} else {
|
||||
Taps { start: content.0, weights: Vec::new(), outside: 1.0 }
|
||||
};
|
||||
}
|
||||
let lo = (u - support).ceil() as i32;
|
||||
let hi = (u + support).floor() as i32;
|
||||
let mut w: Vec<f64> = (lo..=hi).map(|i| filter.weight((i as f64 - u) / stretch)).collect();
|
||||
let s: f64 = w.iter().sum();
|
||||
if s.abs() > 1e-12 {
|
||||
for v in &mut w {
|
||||
*v /= s;
|
||||
}
|
||||
}
|
||||
let (lo, w) = weights(o, scale, kernel, edge);
|
||||
let hi = lo + w.len() as i32 - 1;
|
||||
// Fold the weights of taps beyond the edge onto the edge sample.
|
||||
let start = clamp(lo);
|
||||
let mut folded = vec![0.0f32; (clamp(hi) - start + 1).max(1) as usize];
|
||||
@@ -141,8 +266,7 @@ pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surfac
|
||||
/// keeps it (an opaque layer stays opaque to the border) instead of fading into the default pixel.
|
||||
/// Pixels beyond the canvas count as the surface's own, and inside the canvas nothing changes.
|
||||
pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Rect) -> Surface {
|
||||
let area = s.content_bounds().union(&canvas);
|
||||
resize(s, sx, sy, filter, (!area.is_empty()).then_some(area))
|
||||
resize(s, sx, sy, filter, Some(canvas))
|
||||
}
|
||||
|
||||
/// Samples one source read may hold (64 MB of `f32`); larger windows are read in row chunks.
|
||||
@@ -183,11 +307,37 @@ fn horizontal_dynamic(row: &[f32], out: &mut [f32], taps: &[Taps], x_lo: i32, de
|
||||
}
|
||||
}
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
resize_with_budget(s, sx, sy, filter, edge, READ_BUDGET)
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>) -> Surface {
|
||||
resize_with_budget(s, sx, sy, filter, canvas, READ_BUDGET)
|
||||
}
|
||||
|
||||
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>, budget: usize) -> Surface {
|
||||
/// Runs `filter`'s passes. Between two passes the image is stored in its own format, which
|
||||
/// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so
|
||||
/// the intermediate image is never larger than the result.
|
||||
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>, budget: usize) -> Surface {
|
||||
let mut cur: Option<Surface> = None;
|
||||
let mut canvas = canvas;
|
||||
for p in plan(filter, sx, sy) {
|
||||
let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget);
|
||||
canvas = canvas.map(|c| scaled_rect(c, p.sx, p.sy));
|
||||
cur = Some(next);
|
||||
}
|
||||
let mut out = cur.unwrap_or_else(|| s.clone());
|
||||
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
|
||||
let dst = out.content_bounds();
|
||||
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
|
||||
let area = crate::output_area(&p, dst, dst, None);
|
||||
out = crate::apply(&out, &p, area, dst, None);
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
/// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see
|
||||
/// [`resize_surface_in_canvas`]).
|
||||
fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) -> Surface {
|
||||
let Pass { sx, sy, kx, ky } = pass;
|
||||
let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty());
|
||||
let fmt = s.format();
|
||||
let mut out = Surface::with_default(fmt, &s.default_pixel());
|
||||
let src = s.content_bounds();
|
||||
@@ -199,8 +349,8 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
|
||||
let alpha = fmt.alpha;
|
||||
// With `edge`, every tap is clamped into the edge area, which holds the content.
|
||||
let area = edge.unwrap_or(src);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
|
||||
let hx = taps(dst.x0, dst.x1, sx, kx, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
|
||||
let vy = taps(dst.y0, dst.y1, sy, ky, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
|
||||
let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0);
|
||||
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo);
|
||||
let mut def = s.default_pixel();
|
||||
@@ -312,12 +462,6 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
|
||||
out.write_region(r, &d);
|
||||
}
|
||||
}
|
||||
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
|
||||
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
|
||||
let area = crate::output_area(&p, out.content_bounds(), dst, None);
|
||||
out = crate::apply(&out, &p, area, dst, None);
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -353,7 +497,7 @@ mod tests {
|
||||
fn fixed_channel_rows_match_dynamic_resampling() {
|
||||
fn check<const N: usize>() {
|
||||
for scale in [0.12, 0.5, 1.0, 2.3] {
|
||||
for filter in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
for filter in [Kernel::Nearest, Kernel::Bilinear, keys(Resample::Bicubic, scale), keys(Resample::BicubicSharper, scale), Kernel::Lanczos] {
|
||||
for clamp in [None, Some((-3, 31))] {
|
||||
let ts = taps(-5, 41, scale, filter, clamp, (-3, 31));
|
||||
let row: Vec<f32> = (0..35 * N).map(|i| ((i * 73) % 251) as f32 / 250.0).collect();
|
||||
@@ -374,6 +518,170 @@ mod tests {
|
||||
check::<5>();
|
||||
}
|
||||
|
||||
/// Photoshop 25.4's Image Size of a 16-bit greyscale pattern, in levels of 32768. Covers the
|
||||
/// filters and their 2-D rules: Sharper reducing the width while the height grows is designed
|
||||
/// for the height (not widened) and runs the width first; Bicubic Automatic splits a mixed
|
||||
/// resize into Sharper and Smoother; Bicubic widens for the height's 0.6 while the width
|
||||
/// grows; Smoother below ¼ averages down first; Bilinear below ½ averages whole pixels. On black
|
||||
/// and white the overshoot is clipped between the passes, so their order shows.
|
||||
#[test]
|
||||
fn matches_photoshop_image_size() {
|
||||
// (black and white pattern, filter, size, new size, Photoshop's levels)
|
||||
type Probe = (bool, Resample, (i32, i32), (i32, i32), &'static [u16]);
|
||||
let cases: [Probe; 9] = [
|
||||
(
|
||||
false,
|
||||
Resample::BicubicSharper,
|
||||
(12, 10),
|
||||
(7, 16),
|
||||
&[
|
||||
3777, 13165, 22219, 16176, 10460, 19488, 29643, 11249, 12634, 21696, 13330, 10319, 18965, 29120, 21280, 11204, 20279, 16184, 8200, 17548,
|
||||
27703, 21824, 10182, 19259, 26967, 5238, 16528, 26683, 17366, 8895, 17922, 32768, 2402, 15190, 25345, 17493, 7834, 16903, 31316, 1748,
|
||||
14172, 24326, 24718, 6368, 15746, 30390, 0, 13107, 23169, 31215, 4790, 14456, 27639, 5080, 11597, 21880, 29649, 3864, 13507, 24136, 20600,
|
||||
10264, 20931, 28009, 0, 12446, 21385, 28735, 8754, 19642, 26154, 4049, 10895, 20832, 23523, 7620, 18485, 23204, 20126, 8787, 19818, 21447,
|
||||
6173, 17467, 20390, 31862, 6616, 18480, 24094, 6500, 16129, 19749, 27472, 5810, 17460, 28281, 7655, 15108, 18394, 25515, 4427, 16043,
|
||||
28749, 7026, 13691, 17871, 24992, 3904, 15520, 27200, 6075, 13168,
|
||||
],
|
||||
),
|
||||
(
|
||||
false,
|
||||
Resample::BicubicAutomatic,
|
||||
(12, 10),
|
||||
(7, 16),
|
||||
&[
|
||||
4201, 12337, 27677, 15894, 4816, 20021, 29631, 10348, 9882, 27304, 14492, 3766, 19574, 29046, 18743, 5944, 25299, 16116, 4147, 17829,
|
||||
27787, 19399, 4206, 21790, 23748, 7966, 15117, 26569, 15941, 2695, 19445, 28341, 10104, 13050, 25465, 16251, 2613, 18023, 26770, 8432,
|
||||
11951, 24281, 22800, 7141, 15351, 24790, 5953, 11004, 23130, 28322, 10921, 12440, 24940, 9659, 9368, 22012, 27611, 10006, 10488, 26419,
|
||||
18657, 7071, 20807, 25492, 6937, 9239, 26574, 22363, 5435, 19688, 24808, 9136, 8184, 25295, 19708, 3939, 18677, 25053, 18234, 7120, 24061,
|
||||
17941, 2545, 17568, 24869, 23748, 6081, 21401, 21862, 6018, 15056, 23602, 22177, 4859, 17816, 28450, 11911, 11653, 22253, 20263, 3595,
|
||||
15790, 29734, 12969, 9683, 21668, 19678, 3010, 15424, 28429, 11728, 9300,
|
||||
],
|
||||
),
|
||||
(
|
||||
false,
|
||||
Resample::Bicubic,
|
||||
(12, 10),
|
||||
(20, 6),
|
||||
&[
|
||||
11517, 10000, 8617, 9364, 12272, 16362, 21396, 25508, 25506, 19474, 10887, 5289, 5842, 10286, 14986, 18566, 21910, 25511, 28611, 29922,
|
||||
24788, 17949, 8497, 4593, 7309, 12594, 17150, 20945, 24892, 26196, 22979, 16123, 9691, 7483, 10061, 14653, 18841, 22420, 25514, 26824,
|
||||
25702, 23396, 17586, 11378, 8127, 8882, 12310, 17039, 22345, 25871, 24700, 18125, 9951, 5493, 6802, 11215, 15576, 19259, 22384, 23694,
|
||||
26692, 26985, 22732, 14270, 6889, 4950, 8348, 13583, 17529, 21570, 25966, 27275, 22817, 14643, 8068, 6911, 10694, 15930, 19443, 20705,
|
||||
21499, 23413, 25601, 24336, 18665, 11686, 7800, 8947, 12974, 18067, 22707, 25285, 23077, 16645, 9789, 6586, 8147, 12329, 15935, 17465,
|
||||
17962, 20462, 24615, 25377, 20036, 11458, 5421, 5415, 9528, 14134, 17782, 22482, 26926, 27479, 21881, 13308, 7533, 7734, 11549, 13992,
|
||||
],
|
||||
),
|
||||
(
|
||||
false,
|
||||
Resample::BicubicSmoother,
|
||||
(40, 3),
|
||||
(9, 3),
|
||||
&[
|
||||
11315, 18740, 17475, 15974, 16503, 16042, 18215, 14712, 16238, 18482, 14351, 14664, 18012, 20260, 13338, 15511, 18468, 18275, 16790, 19168,
|
||||
14745, 14998, 20341, 18150, 13819, 16493, 16432,
|
||||
],
|
||||
),
|
||||
(
|
||||
false,
|
||||
Resample::Bilinear,
|
||||
(12, 10),
|
||||
(5, 4),
|
||||
&[11316, 14541, 17920, 10855, 24679, 19763, 9933, 23757, 6247, 20070, 25601, 5325, 19149, 17306, 15463, 20992, 16384, 14541, 23143, 10854],
|
||||
),
|
||||
(
|
||||
false,
|
||||
Resample::Nearest,
|
||||
(12, 10),
|
||||
(7, 16),
|
||||
&[
|
||||
1638, 12698, 23757, 3482, 9011, 20070, 31130, 1638, 12698, 23757, 3482, 9011, 20070, 31130, 31130, 10854, 21914, 1638, 7168, 18227, 29286,
|
||||
29286, 9011, 20070, 31130, 5325, 16384, 27443, 29286, 9011, 20070, 31130, 5325, 16384, 27443, 27443, 7168, 18227, 29286, 3482, 14541,
|
||||
25600, 25600, 5325, 16384, 27443, 1638, 12698, 23757, 25600, 5325, 16384, 27443, 1638, 12698, 23757, 23757, 3482, 14541, 25600, 31130,
|
||||
10854, 21914, 23757, 3482, 14541, 25600, 31130, 10854, 21914, 21914, 1638, 12698, 23757, 29286, 9011, 20070, 20070, 31130, 10854, 21914,
|
||||
27443, 7168, 18227, 20070, 31130, 10854, 21914, 27443, 7168, 18227, 18227, 29286, 9011, 20070, 25600, 5325, 16384, 16384, 27443, 7168,
|
||||
18227, 23757, 3482, 14541, 16384, 27443, 7168, 18227, 23757, 3482, 14541,
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
Resample::Bicubic,
|
||||
(12, 10),
|
||||
(20, 6),
|
||||
&[
|
||||
9960, 6313, 993, 0, 4525, 14807, 26472, 32768, 32768, 23510, 10514, 0, 0, 0, 10110, 22576, 31764, 32768, 32768, 32768, 26136, 18594, 6164,
|
||||
0, 0, 2565, 14499, 26983, 32768, 32768, 26358, 14709, 3969, 0, 0, 6602, 17734, 27952, 32768, 32768, 31590, 27808, 18307, 7429, 0, 0, 3712,
|
||||
14435, 26473, 32768, 31131, 20665, 7535, 0, 0, 2177, 13238, 25049, 32768, 32768, 32768, 32768, 26136, 14174, 2548, 0, 0, 6190, 18705,
|
||||
30273, 32768, 32768, 25517, 13238, 2177, 0, 0, 7250, 19418, 26624, 32768, 32768, 32768, 28203, 18522, 7200, 0, 0, 3285, 14794, 26862,
|
||||
32768, 29913, 19013, 6667, 0, 0, 4443, 14506, 20751, 17344, 23066, 31075, 32361, 23949, 10738, 0, 0, 0, 10110, 22657, 32768, 32768, 32768,
|
||||
22658, 10192, 1004, 0, 0, 0,
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
Resample::BicubicSharper,
|
||||
(12, 10),
|
||||
(7, 16),
|
||||
&[
|
||||
801, 3050, 32489, 16260, 0, 29819, 32767, 9161, 1781, 29578, 14350, 0, 32768, 32767, 21132, 0, 25409, 17862, 0, 26925, 32767, 22769, 0,
|
||||
24839, 27566, 0, 9397, 32768, 19813, 0, 27795, 32768, 0, 0, 32768, 20744, 0, 25797, 32768, 0, 0, 32768, 27555, 0, 11184, 32768, 0, 0,
|
||||
32768, 32768, 0, 0, 32768, 5110, 0, 30386, 32768, 0, 0, 32768, 20385, 0, 23267, 32768, 0, 0, 32768, 29130, 0, 19191, 32768, 2670, 0, 31290,
|
||||
25387, 0, 21263, 32767, 20198, 0, 21586, 24510, 0, 23714, 32768, 32768, 0, 14165, 27804, 1007, 14254, 32768, 30149, 0, 16077, 32112, 2886,
|
||||
1884, 21043, 29607, 0, 16384, 32768, 3566, 0, 12683, 29616, 0, 16384, 32768, 3196, 0,
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
Resample::BicubicSharper,
|
||||
(12, 10),
|
||||
(20, 6),
|
||||
&[
|
||||
9273, 5043, 0, 0, 2698, 14484, 28464, 32768, 32768, 24841, 10877, 0, 0, 0, 9684, 22346, 31492, 32768, 32768, 32768, 25960, 18430, 4978, 0,
|
||||
0, 977, 15047, 28475, 32768, 32768, 27939, 14157, 1813, 0, 0, 5296, 17828, 28439, 32768, 32768, 32768, 27849, 17576, 6209, 0, 0, 2169,
|
||||
15076, 28935, 32768, 32768, 22167, 7399, 0, 0, 211, 12977, 24638, 32768, 32768, 32768, 32768, 25960, 14244, 824, 0, 0, 5093, 18802, 31993,
|
||||
32768, 32768, 26467, 12977, 211, 0, 0, 6300, 19418, 26712, 32768, 32768, 32768, 27907, 17874, 5820, 0, 0, 1853, 16055, 29518, 32768, 32768,
|
||||
20889, 5880, 0, 0, 2879, 13798, 20185, 19010, 25565, 32768, 32302, 25431, 11478, 0, 0, 0, 9684, 23083, 32768, 32768, 32768, 23084, 10422,
|
||||
1276, 0, 0, 0,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (binary, filter, (w, h), (wo, ho), want) in cases {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U16, false));
|
||||
let canvas = Rect::new(0, 0, w, h);
|
||||
let level = |x: i32, y: i32| match ((x * 37 + y * 101) % 17) as f32 {
|
||||
k if binary => {
|
||||
if k > 8.0 {
|
||||
32767.0 / 32768.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
k => ((k / 16.0 * 0.9 + 0.05) * 32768.0).round() / 32768.0,
|
||||
};
|
||||
let px: Vec<f32> = (0..h).flat_map(|y| (0..w).map(move |x| level(x, y))).collect();
|
||||
s.write_region(canvas, &px);
|
||||
let o = resize_surface_in_canvas(&s, wo as f64 / w as f64, ho as f64 / h as f64, filter, canvas);
|
||||
let got = o.read_region(Rect::new(0, 0, wo, ho));
|
||||
assert_eq!(got.len(), want.len(), "{filter:?}");
|
||||
let worst = got.iter().zip(want).map(|(g, &p)| (g * 32768.0 - p as f32).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst <= 8.0, "{filter:?} {w}x{h} to {wo}x{ho}: {worst} levels of 32768 from Photoshop");
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's Bicubic doubles with the Keys cubic at a = −0.75 (Catmull-Rom is −0.5): an
|
||||
/// impulse spreads as −9, −27, 67, 225 /256 and back, as Photoshop 25.4 gives it.
|
||||
#[test]
|
||||
fn bicubic_is_photoshops_keys_cubic() {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
|
||||
let mut row = [0.25; 30];
|
||||
row[10] = 0.75;
|
||||
s.write_region(Rect::new(0, 0, 30, 1), &row);
|
||||
let o = resize_surface(&s, 2.0, 1.0, Resample::Bicubic);
|
||||
let got: Vec<f32> = o.read_region(Rect::new(17, 0, 25, 1)).iter().map(|v| (v - 0.25) * 512.0).collect();
|
||||
for (g, want) in got.iter().zip([-9.0, -27.0, 67.0, 225.0, 225.0, 67.0, -27.0, -9.0]) {
|
||||
assert!((g - want).abs() < 1e-3, "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_scale_is_lossless() {
|
||||
for f in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
|
||||
@@ -16,6 +16,13 @@ use photocraft_raster::{Cancelled, Interrupt};
|
||||
/// Energy added per unit of protection: far above any gradient magnitude.
|
||||
const PROTECT_ENERGY: f32 = 1.0e4;
|
||||
|
||||
/// Keep full horizontal detail while reducing the height used to find vertical seams. At a
|
||||
/// 24 MP working size this cuts the dynamic-programming work by about four times without
|
||||
/// quantising the output pixels or changing the requested output dimensions.
|
||||
const SEAM_SEARCH_MAX_HEIGHT: usize = 512;
|
||||
const PROXY_SHRINK_MIN_HEIGHT: usize = 2_048;
|
||||
const PROXY_SHRINK_MIN_SEAMS: usize = 64;
|
||||
|
||||
/// Gradient-magnitude energy (the paper's e1, `|∂x| + |∂y|` summed over channels). Forward and
|
||||
/// backward differences are both counted so one-pixel lines (whose central difference is zero)
|
||||
/// carry energy too.
|
||||
@@ -248,6 +255,9 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
|
||||
}
|
||||
let seams = new_w.abs_diff(w).max(1) as f32;
|
||||
if new_w < w {
|
||||
if h >= PROXY_SHRINK_MIN_HEIGHT && w - new_w >= PROXY_SHRINK_MIN_SEAMS {
|
||||
return carve_width_proxy_height(w, h, ch, img, protect, new_w, ctl);
|
||||
}
|
||||
let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false);
|
||||
while carver.cw > new_w {
|
||||
ctl.check()?;
|
||||
@@ -310,6 +320,89 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Find large-image seams on vertically averaged rows, then apply those paths to the original
|
||||
/// pixels. The proxy has the same width, so each seam still removes exactly one source pixel per
|
||||
/// row. Keeping output pixels at full resolution avoids the quality loss of resizing the result;
|
||||
/// only the seam decisions use the coarser vertical signal. Small operations retain the exact
|
||||
/// full-resolution algorithm above.
|
||||
fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
|
||||
let step = h.div_ceil(SEAM_SEARCH_MAX_HEIGHT);
|
||||
let proxy_h = h.div_ceil(step);
|
||||
let row_len = w * ch;
|
||||
let proxy_row_len = proxy_h * row_len;
|
||||
let mut proxy = vec![0.0; proxy_row_len];
|
||||
for (py, out_row) in proxy.chunks_exact_mut(row_len).enumerate() {
|
||||
let y0 = py * step;
|
||||
let y1 = (y0 + step).min(h);
|
||||
let divisor = (y1 - y0) as f32;
|
||||
for y in y0..y1 {
|
||||
let in_row = &img[y * row_len..(y + 1) * row_len];
|
||||
for (out, &sample) in out_row.iter_mut().zip(in_row) {
|
||||
*out += sample / divisor;
|
||||
}
|
||||
}
|
||||
}
|
||||
let proxy_protect = protect.map(|mask| {
|
||||
let mut reduced = vec![0.0; proxy_h * w];
|
||||
for (py, out_row) in reduced.chunks_exact_mut(w).enumerate() {
|
||||
let y0 = py * step;
|
||||
let y1 = (y0 + step).min(h);
|
||||
let divisor = (y1 - y0) as f32;
|
||||
for y in y0..y1 {
|
||||
let in_row = &mask[y * w..(y + 1) * w];
|
||||
for (out, &sample) in out_row.iter_mut().zip(in_row) {
|
||||
*out += sample / divisor;
|
||||
}
|
||||
}
|
||||
}
|
||||
reduced
|
||||
});
|
||||
|
||||
let seam_count = w - new_w;
|
||||
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true);
|
||||
let mut removed = Vec::with_capacity(seam_count);
|
||||
while carver.cw > new_w {
|
||||
ctl.check()?;
|
||||
removed.push(carver.remove_seam());
|
||||
ctl.progress(removed.len() as f32 / seam_count as f32);
|
||||
}
|
||||
|
||||
let mut removed_by_row = Vec::with_capacity(proxy_h);
|
||||
for proxy_y in 0..proxy_h {
|
||||
let mut columns: Vec<usize> = removed.iter().map(|path| path[proxy_y]).collect();
|
||||
columns.sort_unstable();
|
||||
removed_by_row.push(columns);
|
||||
}
|
||||
let mut out = Vec::with_capacity(new_w * h * ch);
|
||||
let mut columns = vec![0; seam_count];
|
||||
for y in 0..h {
|
||||
ctl.check()?;
|
||||
let proxy_y = (y / step).min(proxy_h - 1);
|
||||
let next_proxy_y = (proxy_y + 1).min(proxy_h - 1);
|
||||
let blend = (y % step) as f32 / step as f32;
|
||||
let lower = &removed_by_row[proxy_y];
|
||||
let upper = &removed_by_row[next_proxy_y];
|
||||
let mut min_x = 0;
|
||||
for (i, x) in columns.iter_mut().enumerate() {
|
||||
let interpolated = lower[i] as f32 + (upper[i] as f32 - lower[i] as f32) * blend;
|
||||
let max_x = w - (seam_count - i);
|
||||
*x = (interpolated.round() as usize).clamp(min_x, max_x);
|
||||
min_x = x.saturating_add(1);
|
||||
}
|
||||
let mut next = 0;
|
||||
let row = &img[y * row_len..(y + 1) * row_len];
|
||||
for (x, pixel) in row.chunks_exact(ch).enumerate() {
|
||||
if columns.get(next) == Some(&x) {
|
||||
next += 1;
|
||||
} else {
|
||||
out.extend_from_slice(pixel);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctl.progress(1.0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
|
||||
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
|
||||
// Never cancelled; the fallback is unreachable.
|
||||
@@ -639,6 +732,35 @@ mod tests {
|
||||
assert!(red_columns(24, h, &plain) < 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_proxy_shrink_preserves_full_resolution_salient_and_protected_pixels() {
|
||||
let (w, h, ch, new_w) = (512, PROXY_SHRINK_MIN_HEIGHT, 3, 384);
|
||||
let mut img = bar(w, h);
|
||||
let mut protect = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
let i = (y * w + 256) * ch;
|
||||
img[i..i + ch].copy_from_slice(&[0.03, 0.87, 0.42]);
|
||||
protect[y * w + 256] = 1.0;
|
||||
}
|
||||
|
||||
let out = carve_width_with(w, h, ch, &img, Some(&protect), new_w, &Interrupt::NONE).unwrap();
|
||||
|
||||
assert_eq!(out.len(), new_w * h * ch);
|
||||
assert_eq!(red_columns(new_w, h, &out), 4, "the full-resolution bar survives");
|
||||
for y in 0..h {
|
||||
let row = &out[y * new_w * ch..(y + 1) * new_w * ch];
|
||||
assert!(row.chunks_exact(ch).any(|px| px == [0.03, 0.87, 0.42]), "protected mark survives row {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_proxy_shrink_observes_cancellation_before_modifying_source() {
|
||||
let (w, h, ch) = (128, PROXY_SHRINK_MIN_HEIGHT, 3);
|
||||
let img = vec![0.5; w * h * ch];
|
||||
let cancelled = || true;
|
||||
assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enlarging_duplicates_flat_areas() {
|
||||
let (w, h) = (30, 8);
|
||||
|
||||
@@ -23,6 +23,12 @@ const INF_DIST: u32 = u32::MAX;
|
||||
/// Residual capacities at or below this count as saturated (guards against float round-off
|
||||
/// leaving tiny positive residuals that would cause long chains of negligible augmentations).
|
||||
const EPS: f32 = 1e-5;
|
||||
/// How many steps the max-flow loops take between asks whether to stop.
|
||||
const CANCEL_EVERY: u32 = 1024;
|
||||
|
||||
fn never() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// A directed graph with source/sink terminal capacities.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -110,24 +116,35 @@ impl Graph {
|
||||
|
||||
/// Computes the maximum flow (= minimum cut capacity).
|
||||
pub fn maxflow(&mut self) -> f64 {
|
||||
self.maxflow_with(&never).unwrap_or(self.flow)
|
||||
}
|
||||
|
||||
/// [`Graph::maxflow`] that stops when `cancel` returns true (asked every few thousand steps):
|
||||
/// `None` then, and the cut is meaningless. Not cancelled, it does exactly what
|
||||
/// [`Graph::maxflow`] does.
|
||||
pub fn maxflow_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
|
||||
if self.node_count() >= 4096 {
|
||||
let mut source = false;
|
||||
let mut sink = false;
|
||||
for &capacity in &self.tr_cap {
|
||||
if !capacity.is_finite() {
|
||||
return self.maxflow_bk();
|
||||
return self.bk(cancel);
|
||||
}
|
||||
source |= capacity > EPS;
|
||||
sink |= capacity < -EPS;
|
||||
}
|
||||
if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||||
return self.maxflow_push_relabel();
|
||||
return self.push_relabel(cancel);
|
||||
}
|
||||
}
|
||||
self.maxflow_bk()
|
||||
self.bk(cancel)
|
||||
}
|
||||
|
||||
pub(super) fn maxflow_bk(&mut self) -> f64 {
|
||||
self.bk(&never).unwrap_or(self.flow)
|
||||
}
|
||||
|
||||
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
|
||||
let n = self.node_count();
|
||||
let mut queue: VecDeque<u32> = VecDeque::new();
|
||||
let mut orphans: VecDeque<u32> = VecDeque::new();
|
||||
@@ -145,7 +162,12 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
let mut time = 0u32;
|
||||
let mut steps = 0u32;
|
||||
while let Some(i) = queue.pop_front() {
|
||||
steps = steps.wrapping_add(1);
|
||||
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
|
||||
return None;
|
||||
}
|
||||
let i = i as usize;
|
||||
self.active[i] = false;
|
||||
if self.parent[i] == NONE {
|
||||
@@ -197,7 +219,7 @@ impl Graph {
|
||||
self.adopt(o as usize, time, &mut queue, &mut orphans);
|
||||
}
|
||||
}
|
||||
self.flow
|
||||
Some(self.flow)
|
||||
}
|
||||
|
||||
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
|
||||
|
||||
@@ -5,14 +5,20 @@
|
||||
//! The existing SoA residual graph is reused. Terminal source arcs are saturated
|
||||
//! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local
|
||||
//! f32 capacity updates do not accumulate excess-rounding drift.
|
||||
use super::{EPS, Graph, NONE, TERMINAL};
|
||||
use super::{CANCEL_EVERY, EPS, Graph, NONE, TERMINAL};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
impl Graph {
|
||||
#[cfg(test)]
|
||||
pub(super) fn maxflow_push_relabel(&mut self) -> f64 {
|
||||
self.push_relabel(&super::never).unwrap_or(self.flow)
|
||||
}
|
||||
|
||||
/// Push/relabel; `None` when `cancel` stopped it.
|
||||
pub(super) fn push_relabel(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
|
||||
let n = self.node_count();
|
||||
let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else {
|
||||
return self.maxflow_bk();
|
||||
return self.bk(cancel);
|
||||
};
|
||||
let mut back = vec![0.0f32; n];
|
||||
let mut excess = vec![0.0f64; n];
|
||||
@@ -34,7 +40,12 @@ impl Graph {
|
||||
}
|
||||
let mut work = 0usize;
|
||||
let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n);
|
||||
let mut steps = 0u32;
|
||||
while let Some(i) = queue.pop_front() {
|
||||
steps = steps.wrapping_add(1);
|
||||
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
|
||||
return None;
|
||||
}
|
||||
self.active[i] = false;
|
||||
while excess[i] > f64::from(EPS) {
|
||||
if self.dist[i] == 1 && self.tr_cap[i] < -EPS {
|
||||
@@ -127,7 +138,7 @@ impl Graph {
|
||||
a = self.next[ai];
|
||||
}
|
||||
}
|
||||
self.flow
|
||||
Some(self.flow)
|
||||
}
|
||||
|
||||
fn global_relabel(&mut self, source_height: u32, back: &[f32]) {
|
||||
|
||||
@@ -242,14 +242,38 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[
|
||||
grid_cut_weighted::<true>(img, cost_fg, cost_bg, fixed, weight)
|
||||
}
|
||||
|
||||
/// [`grid_cut_with`] that stops when `cancel` returns true (asked every few thousand max-flow
|
||||
/// steps): `None` then. Not cancelled, the same cut.
|
||||
pub fn grid_cut_cancellable(
|
||||
img: &RgbImage,
|
||||
cost_fg: &[f32],
|
||||
cost_bg: &[f32],
|
||||
fixed: &[u8],
|
||||
weight: impl Fn(f32) -> f32,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> Option<Vec<bool>> {
|
||||
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
|
||||
}
|
||||
|
||||
mod edge_starts;
|
||||
|
||||
// The const parameter also runs the previous full scan in before/after workflow tests.
|
||||
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
|
||||
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn grid_cut_impl<const OPTIMIZED: bool>(
|
||||
img: &RgbImage,
|
||||
cost_fg: &[f32],
|
||||
cost_bg: &[f32],
|
||||
fixed: &[u8],
|
||||
weight: impl Fn(f32) -> f32,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> Option<Vec<bool>> {
|
||||
let (w, h) = (img.w as i32, img.h as i32);
|
||||
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() };
|
||||
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
|
||||
if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) {
|
||||
return Vec::new();
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let mut free = Vec::new();
|
||||
let mut idx = vec![u32::MAX; n];
|
||||
@@ -262,7 +286,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
|
||||
}
|
||||
let mut out: Vec<bool> = fixed.iter().map(|f| *f == HARD_FG).collect();
|
||||
if count == 0 {
|
||||
return out;
|
||||
return Some(out);
|
||||
}
|
||||
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
|
||||
for i in 0..n {
|
||||
@@ -307,7 +331,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
|
||||
}
|
||||
}
|
||||
if OPTIMIZED {
|
||||
g.maxflow();
|
||||
g.maxflow_with(cancel)?;
|
||||
} else {
|
||||
g.maxflow_bk();
|
||||
}
|
||||
@@ -316,7 +340,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
|
||||
out[i] = g.in_source(idx[i] as usize);
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn add_fixed_link(g: &mut Graph, node: usize, fixed: u8, wt: f32) {
|
||||
|
||||
@@ -35,6 +35,35 @@ fn maxflow_clrs_network() {
|
||||
assert_eq!(g.residual_out_of_source_side(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maxflow_can_be_cancelled_and_otherwise_cuts_the_same() {
|
||||
// A 120 × 100 grid with terminal links both ways (push/relabel) and a small one (BK).
|
||||
for (w, h) in [(120usize, 100usize), (20, 15)] {
|
||||
let mut g = Graph::with_capacity(w * h, w * h * 2);
|
||||
let mut z = 7u64;
|
||||
let mut next = || {
|
||||
z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(z >> 40) as f32 / (1u64 << 24) as f32
|
||||
};
|
||||
for i in 0..w * h {
|
||||
g.add_tweights(i, next() * 2.0, next() * 2.0);
|
||||
if i % w + 1 < w {
|
||||
g.add_edge(i, i + 1, next(), next());
|
||||
}
|
||||
if i + w < w * h {
|
||||
g.add_edge(i, i + w, next(), next());
|
||||
}
|
||||
}
|
||||
let (mut plain, mut asked) = (g.clone(), g.clone());
|
||||
let flow = plain.maxflow();
|
||||
assert_eq!(asked.maxflow_with(&|| false), Some(flow));
|
||||
assert!((0..w * h).all(|i| plain.in_source(i) == asked.in_source(i)));
|
||||
if w * h > 4096 {
|
||||
assert_eq!(g.maxflow_with(&|| true), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference max flow (Edmonds–Karp on a dense matrix; node 0 = s, n−1 = t).
|
||||
fn edmonds_karp(cap: &mut [Vec<f64>]) -> f64 {
|
||||
let n = cap.len();
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
//! modify (expand, contract, border, smooth, feather), anti-aliased lasso,
|
||||
//! and combining with an existing selection.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
|
||||
/// How a new selection combines with the current one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -79,49 +81,57 @@ fn coverage8(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from 8-bit coverage over `area`, allocating only the tiles
|
||||
/// that hold something (as [`Surface::prune`] would leave them).
|
||||
fn surface_from_coverage8(bytes: &[u8], area: Rect) -> Surface {
|
||||
/// Builds a GRAY8 selection surface over `area` (masks row-major over it) whose coverage at
|
||||
/// each sample is `f(prev, new)`, `prev` reading 0 without a `prev` mask and missing samples
|
||||
/// reading 0. Tiles are filled in parallel straight from the masks, a row at a time (no
|
||||
/// full-canvas byte buffer); only the tiles that hold something are kept (as [`Surface::prune`]
|
||||
/// would leave them), and fully selected tiles share one tile (copy-on-write), so the inside of a
|
||||
/// large selection costs no memory per tile (#211).
|
||||
fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
if area.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let w = area.width() as usize;
|
||||
let ts = TILE_SIZE as usize;
|
||||
for tc in area.tiles() {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
|
||||
let row = |y: i32| bytes.get((y - area.y0) as usize * w + lx..(y - area.y0) as usize * w + lx + span);
|
||||
if !(tr.y0..tr.y1).any(|y| row(y).is_some_and(|r| r.iter().any(|b| *b != 0))) {
|
||||
continue;
|
||||
}
|
||||
let data = s.tile_mut(tc).bytes_mut();
|
||||
for y in tr.y0..tr.y1 {
|
||||
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
|
||||
if let (Some(src), Some(dst)) = (row(y), data.get_mut(o..o + span)) {
|
||||
dst.copy_from_slice(src);
|
||||
let full = s.solid_tile(&[1.0]);
|
||||
let row = |i0: usize, dst: &mut [u8]| {
|
||||
let n = dst.len();
|
||||
match (new.get(i0..i0 + n), prev.map(|p| p.get(i0..i0 + n))) {
|
||||
(Some(sn), None) => dst.iter_mut().zip(sn).for_each(|(d, v)| *d = coverage8(f(0.0, *v))),
|
||||
(Some(sn), Some(Some(sp))) => dst.iter_mut().zip(sn.iter().zip(sp)).for_each(|(d, (v, p))| *d = coverage8(f(*p, *v))),
|
||||
_ => {
|
||||
let at = |m: &[f32], i: usize| m.get(i).copied().unwrap_or(0.0);
|
||||
for (i, d) in (i0..).zip(dst.iter_mut()) {
|
||||
*d = coverage8(f(prev.map_or(0.0, |p| at(p, i)), at(new, i)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Every tile starts as one shared blank tile; `Arc::make_mut` gives each task its own copy.
|
||||
let blanks = s.take_tiles(area);
|
||||
let built = par_map(blanks.len(), |k| {
|
||||
let (tc, blank) = blanks.get(k)?;
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
|
||||
let mut tile = Arc::clone(blank);
|
||||
let data = Arc::make_mut(&mut tile).bytes_mut();
|
||||
let (mut lo, mut hi) = (if tr == tc.rect() { u8::MAX } else { 0 }, 0u8);
|
||||
for y in tr.y0..tr.y1 {
|
||||
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
|
||||
let Some(dst) = data.get_mut(o..o + span) else { continue };
|
||||
row((y - area.y0) as usize * w + lx, dst);
|
||||
(lo, hi) = dst.iter().fold((lo, hi), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
|
||||
}
|
||||
(hi > 0).then(|| (*tc, if lo == u8::MAX { Arc::clone(&full) } else { tile }))
|
||||
});
|
||||
s.put_tiles(built.into_iter().flatten());
|
||||
s
|
||||
}
|
||||
|
||||
/// 8-bit coverage of `m` (missing samples are 0), computed in parallel.
|
||||
fn to_coverage8(m: &[f32], area: Rect, f: impl Fn(usize, f32) -> f32 + Sync + Send) -> Vec<u8> {
|
||||
let w = area.width() as usize;
|
||||
let mut bytes = vec![0u8; w * area.height() as usize];
|
||||
par_rows(&mut bytes, w, 1, |y, row| {
|
||||
for (x, b) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
*b = coverage8(f(i, m.get(i).copied().unwrap_or(0.0)));
|
||||
}
|
||||
});
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from a mask over `area`.
|
||||
/// Builds a GRAY8 selection surface from a mask over `area` (missing samples are 0).
|
||||
pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
if area.is_empty() {
|
||||
return Surface::new(PixelFormat::GRAY8);
|
||||
}
|
||||
surface_from_coverage8(&to_coverage8(m, area, |_, v| v), area)
|
||||
surface_from_masks(area, m, None, |_, v| v)
|
||||
}
|
||||
|
||||
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
|
||||
@@ -132,22 +142,18 @@ pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMo
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
|
||||
// exact. Below half an 8-bit step is nothing.
|
||||
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
|
||||
let bytes = match mode {
|
||||
SelectionMode::Replace => to_coverage8(new, area, |_, v| v),
|
||||
let s = match mode {
|
||||
SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v),
|
||||
_ => {
|
||||
let prev = mask_from_surface(old, area);
|
||||
let at = |i: usize| prev.get(i).copied().unwrap_or(0.0);
|
||||
match mode {
|
||||
SelectionMode::Add => to_coverage8(new, area, |i, v| at(i).max(q(v))),
|
||||
SelectionMode::Subtract => to_coverage8(new, area, |i, v| (at(i) - q(v)).max(0.0)),
|
||||
_ => to_coverage8(new, area, |i, v| at(i).min(q(v))),
|
||||
SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))),
|
||||
SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)),
|
||||
_ => surface_from_masks(area, new, Some(&prev), |p, v| p.min(q(v))),
|
||||
}
|
||||
}
|
||||
};
|
||||
if bytes.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(surface_from_coverage8(&bytes, area))
|
||||
(s.tile_count() > 0).then_some(s)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||||
@@ -710,8 +716,10 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
|
||||
}
|
||||
|
||||
mod distance;
|
||||
mod marquee;
|
||||
|
||||
pub use distance::edt;
|
||||
pub use marquee::{combine_surfaces, ellipse_surface};
|
||||
|
||||
/// Grows the selection by `r` pixels.
|
||||
pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
@@ -1202,7 +1210,10 @@ mod tests {
|
||||
let mut rng = crate::photo_util::Rng(7);
|
||||
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
|
||||
// Off-grid areas spanning several tiles, including negative origins.
|
||||
for area in [Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6)] {
|
||||
// The last one holds whole tiles, which fully selected masks fill (shared tiles).
|
||||
for area in
|
||||
[Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6), Rect::new(-37, -300, 530, 263)]
|
||||
{
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
let old_m: Vec<f32> = (0..n).map(|i| if i % 97 < 40 { u() } else { 0.0 }).collect();
|
||||
let old = old_mask_to_surface(&old_m, area);
|
||||
@@ -1211,7 +1222,7 @@ mod tests {
|
||||
let mut slow_read = Vec::new();
|
||||
old.read_region_into(area, &mut slow_read);
|
||||
assert_eq!(mask_from_surface(Some(&old), area), slow_read);
|
||||
for kind in 0..3 {
|
||||
for kind in 0..4 {
|
||||
let new: Vec<f32> = (0..n)
|
||||
.map(|i| match kind {
|
||||
0 => u() * 1.2 - 0.1,
|
||||
@@ -1222,7 +1233,8 @@ mod tests {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => 0.001,
|
||||
2 => 0.001,
|
||||
_ => f32::from(u8::from(i >= 1000)),
|
||||
})
|
||||
.collect();
|
||||
for mode in [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect] {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Marquee shapes built straight into tiles, and the tile-wise combination of two selections
|
||||
//! (#2888, part of #211).
|
||||
//!
|
||||
//! Both produce exactly the bytes the per-pixel versions did (an ellipse written with
|
||||
//! `Surface::write_pixel`, a combination read and written through whole-canvas `f32` buffers),
|
||||
//! but work a tile at a time in parallel, keep only the tiles that hold something (as
|
||||
//! `Surface::prune` would leave them) and share one tile for every fully selected one.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::{PixelFormat, SampleType, read_sample, write_sample};
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
use crate::photo_util::par_map;
|
||||
|
||||
const TS: usize = TILE_SIZE as usize;
|
||||
|
||||
/// The byte a GRAY8 surface stores for `v`.
|
||||
fn encode8(v: f32) -> u8 {
|
||||
let mut b = [0u8];
|
||||
write_sample(&mut b, SampleType::U8, 0, v);
|
||||
let [b] = b;
|
||||
b
|
||||
}
|
||||
|
||||
/// The value a GRAY8 surface reads for `b`.
|
||||
fn decode8(b: u8) -> f32 {
|
||||
read_sample(&[b], SampleType::U8, 0)
|
||||
}
|
||||
|
||||
/// An ellipse inscribed in a rectangle, tested in `f32` exactly as the Elliptical Marquee
|
||||
/// always has (so its anti-aliased edge keeps the same bytes).
|
||||
struct Ellipse {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
}
|
||||
|
||||
impl Ellipse {
|
||||
fn new(r: Rect) -> Self {
|
||||
Self {
|
||||
cx: (i64::from(r.x0) + i64::from(r.x1)) as f32 / 2.0,
|
||||
cy: (i64::from(r.y0) + i64::from(r.y1)) as f32 / 2.0,
|
||||
rx: r.width() as f32 / 2.0,
|
||||
ry: r.height() as f32 / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn inside(&self, x: f32, y: f32) -> bool {
|
||||
let (dx, dy) = ((x - self.cx) / self.rx, (y - self.cy) / self.ry);
|
||||
dx * dx + dy * dy <= 1.0
|
||||
}
|
||||
|
||||
/// Whether every point of pixel `(x, y)` that the coverage samples is inside: its four
|
||||
/// corners (anti-aliased) or its centre. Along a row the pixels where this holds form one
|
||||
/// run: each test is monotonic in the distance from the centre (IEEE rounding is monotonic).
|
||||
#[inline]
|
||||
fn full(&self, x: i32, y: i32, aa: bool) -> bool {
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
if !aa {
|
||||
return self.inside(fx + 0.5, fy + 0.5);
|
||||
}
|
||||
self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0)
|
||||
}
|
||||
|
||||
/// Coverage of pixel `(x, y)` in sixteenths: 16 when [`Ellipse::full`], else (anti-aliased)
|
||||
/// the number of its 4×4 sub-samples inside.
|
||||
#[inline]
|
||||
fn coverage(&self, x: i32, y: i32, aa: bool) -> u8 {
|
||||
if self.full(x, y, aa) {
|
||||
return 16;
|
||||
}
|
||||
if !aa {
|
||||
return 0;
|
||||
}
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
(0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count() as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the ellipse: nothing outside `lo..hi`, fully selected in `full_lo..full_hi`, and
|
||||
/// the edge pixels in between (`lo..full_lo`, `full_hi..hi`) need the exact coverage test.
|
||||
#[derive(Clone, Copy)]
|
||||
struct RowSpan {
|
||||
lo: i32,
|
||||
full_lo: i32,
|
||||
full_hi: i32,
|
||||
hi: i32,
|
||||
}
|
||||
|
||||
/// Bounds row `y` of `e` within `cut.x0..cut.x1` from the exact (f64) ellipse, widened by a
|
||||
/// margin that covers the f32 tests' rounding. The bounds are then checked with the exact tests
|
||||
/// (the fully selected run at both ends, nothing just outside); any doubt evaluates the whole row.
|
||||
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
|
||||
let exact = RowSpan { lo: cut.x0, full_lo: cut.x1, full_hi: cut.x1, hi: cut.x1 };
|
||||
let (cx, cy, rx, ry) = (f64::from(e.cx), f64::from(e.cy), f64::from(e.rx), f64::from(e.ry));
|
||||
// Every point the tests sample in this row lies in `y..y + 1`.
|
||||
let (ya, yb) = (f64::from(y), f64::from(y) + 1.0);
|
||||
let near = if cy < ya { ya - cy } else { (cy - yb).max(0.0) };
|
||||
let far = (ya - cy).abs().max((yb - cy).abs());
|
||||
let half = |d: f64| {
|
||||
let t = 1.0 - (d / ry) * (d / ry);
|
||||
if t >= 0.0 { rx * t.sqrt() } else { -1.0 }
|
||||
};
|
||||
let (outer, inner) = (half(near).max(0.0), half(far));
|
||||
// Near the top and bottom a rounding error δ in the test moves the edge by up to rx·√δ.
|
||||
let m = 2.0 + rx * 1e-3;
|
||||
if !(outer.is_finite() && inner.is_finite() && m.is_finite()) {
|
||||
return exact;
|
||||
}
|
||||
let px = |v: f64| v.clamp(f64::from(cut.x0), f64::from(cut.x1)) as i32;
|
||||
let lo = px((cx - outer - m).floor());
|
||||
let hi = px((cx + outer + m).floor() + 1.0);
|
||||
let (mut full_lo, mut full_hi) = (px((cx - inner + m).ceil()), px((cx + inner - m).floor()));
|
||||
if inner < 0.0 || full_lo >= full_hi {
|
||||
(full_lo, full_hi) = (hi, hi);
|
||||
}
|
||||
let empty = |x: i32| e.coverage(x, y, aa) == 0;
|
||||
let ok = (lo <= cut.x0 || empty(lo - 1)) && (hi >= cut.x1 || empty(hi)) && (full_lo >= full_hi || (e.full(full_lo, y, aa) && e.full(full_hi - 1, y, aa)));
|
||||
if ok { RowSpan { lo, full_lo, full_hi, hi } } else { exact }
|
||||
}
|
||||
|
||||
/// The Elliptical Marquee's shape: the ellipse inscribed in `r`, cut to `clip`, as a GRAY8
|
||||
/// selection. Anti-aliased edge pixels hold the fraction of their 4×4 sub-samples inside;
|
||||
/// without anti-aliasing a pixel is selected when its centre is inside.
|
||||
pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
let cut = r.intersect(&clip);
|
||||
if cut.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let e = Ellipse::new(r);
|
||||
let enc: [u8; 17] = std::array::from_fn(|n| encode8(n as f32 / 16.0));
|
||||
let one = encode8(1.0);
|
||||
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias));
|
||||
let span = |y: i32| rows.get(usize::try_from(y - cut.y0).ok()?);
|
||||
let full = s.solid_tile(&[1.0]);
|
||||
let blank = s.solid_tile(&[0.0]);
|
||||
let coords: Vec<TileCoord> = cut.tiles().collect();
|
||||
let built = par_map(coords.len(), |k| {
|
||||
let tc = *coords.get(k)?;
|
||||
let rect = tc.rect();
|
||||
let tr = rect.intersect(&cut);
|
||||
if tr == rect && (tr.y0..tr.y1).all(|y| span(y).is_some_and(|s| s.full_lo <= tr.x0 && s.full_hi >= tr.x1)) {
|
||||
return Some((tc, Arc::clone(&full)));
|
||||
}
|
||||
let mut tile: Option<Tile> = None;
|
||||
for y in tr.y0..tr.y1 {
|
||||
let Some(sp) = span(y) else { continue };
|
||||
let row = (y - rect.y0) as usize * TS;
|
||||
let at = |x: i32| row + (x - rect.x0) as usize;
|
||||
let (a, b) = (sp.full_lo.max(tr.x0), sp.full_hi.min(tr.x1));
|
||||
if a < b
|
||||
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(a)..at(b))
|
||||
{
|
||||
d.fill(one);
|
||||
}
|
||||
for x in (sp.lo.max(tr.x0)..sp.full_lo.min(tr.x1)).chain(sp.full_hi.max(tr.x0)..sp.hi.min(tr.x1)) {
|
||||
let n = e.coverage(x, y, anti_alias);
|
||||
if n > 0
|
||||
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(x))
|
||||
{
|
||||
*d = enc.get(usize::from(n)).copied().unwrap_or(one);
|
||||
}
|
||||
}
|
||||
}
|
||||
tile.map(|t| (tc, Arc::new(t)))
|
||||
});
|
||||
s.put_tiles(built.into_iter().flatten());
|
||||
s
|
||||
}
|
||||
|
||||
/// `f(a, b)` per pixel over `area` for two selections, as a GRAY8 selection holding nothing
|
||||
/// outside `area`: the same bytes as reading both over `area`, mapping and writing the result.
|
||||
/// GRAY8 inputs (every selection) go tile by tile in parallel through a table of all 256×256
|
||||
/// byte pairs; a tile `f` leaves unchanged stays shared with its input.
|
||||
pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
|
||||
let mut out = Surface::new(PixelFormat::GRAY8);
|
||||
if area.is_empty() {
|
||||
return out;
|
||||
}
|
||||
if a.format() != PixelFormat::GRAY8 || b.format() != PixelFormat::GRAY8 {
|
||||
let (ra, rb) = (a.read_region(area), b.read_region(area));
|
||||
let data: Vec<f32> = ra.iter().zip(&rb).map(|(x, y)| f(*x, *y)).collect();
|
||||
if data.len() == area.width() as usize * area.height() as usize {
|
||||
out.write_region(area, &data);
|
||||
out.prune();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let lut: Vec<u8> = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect();
|
||||
let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0);
|
||||
let (da, db) = (a.default_bytes().first().copied().unwrap_or(0), b.default_bytes().first().copied().unwrap_or(0));
|
||||
// Whether `f` passes one side through where the other side has no tile (e.g. Add, Subtract).
|
||||
let keeps_a = (0..=255u8).all(|v| pair(v, db) == v);
|
||||
let keeps_b = (0..=255u8).all(|v| pair(da, v) == v);
|
||||
let (full, blank) = (out.solid_tile(&[1.0]), out.solid_tile(&[0.0]));
|
||||
let (row_a, row_b) = ([da; TS], [db; TS]);
|
||||
let coords: Vec<TileCoord> = area.tiles().collect();
|
||||
let built = par_map(coords.len(), |k| {
|
||||
let tc = *coords.get(k)?;
|
||||
let rect = tc.rect();
|
||||
let tr = rect.intersect(&area);
|
||||
let (ta, tb) = (a.tile(tc), b.tile(tc));
|
||||
if tr == rect {
|
||||
let pass = match (ta, tb) {
|
||||
(Some(t), None) if keeps_a => Some(t),
|
||||
(None, Some(t)) if keeps_b => Some(t),
|
||||
(None, None) if pair(da, db) == 0 => return None,
|
||||
_ => None,
|
||||
};
|
||||
if let Some(t) = pass {
|
||||
return t.bytes().iter().any(|v| *v != 0).then(|| (tc, Arc::clone(t)));
|
||||
}
|
||||
}
|
||||
let mut tile = Tile::clone(&blank);
|
||||
let data = tile.bytes_mut();
|
||||
let (mut lo, mut hi) = (if tr == rect { u8::MAX } else { 0 }, 0u8);
|
||||
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
|
||||
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
|
||||
let (s, e) = (ly * TS + x0, ly * TS + x1);
|
||||
let src_a = ta.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_a[..x1 - x0]);
|
||||
let src_b = tb.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_b[..x1 - x0]);
|
||||
let Some(dst) = data.get_mut(s..e) else { continue };
|
||||
for (d, (x, y)) in dst.iter_mut().zip(src_a.iter().zip(src_b)) {
|
||||
*d = pair(*x, *y);
|
||||
(lo, hi) = (lo.min(*d), hi.max(*d));
|
||||
}
|
||||
}
|
||||
(hi > 0).then(|| (tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }))
|
||||
});
|
||||
out.put_tiles(built.into_iter().flatten());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Elliptical Marquee as it was: every pixel tested and written one at a time.
|
||||
fn old_ellipse(r: Rect, area: Rect, aa: bool) -> Surface {
|
||||
let cut = r.intersect(&area);
|
||||
let mut shape = Surface::new(PixelFormat::GRAY8);
|
||||
let (cx, cy) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0);
|
||||
let (rx, ry) = (r.width() as f32 / 2.0, r.height() as f32 / 2.0);
|
||||
let inside = |x: f32, y: f32| {
|
||||
let (dx, dy) = ((x - cx) / rx, (y - cy) / ry);
|
||||
dx * dx + dy * dy <= 1.0
|
||||
};
|
||||
for y in cut.y0..cut.y1 {
|
||||
for x in cut.x0..cut.x1 {
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count();
|
||||
let cov = if !aa {
|
||||
if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 }
|
||||
} else if corners == 4 {
|
||||
1.0
|
||||
} else {
|
||||
let n = (0..16).filter(|i| inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count();
|
||||
n as f32 / 16.0
|
||||
};
|
||||
if cov > 0.0 {
|
||||
shape.write_pixel(x, y, &[cov]);
|
||||
}
|
||||
}
|
||||
}
|
||||
shape
|
||||
}
|
||||
|
||||
/// Combining two selections as it was: whole-area `f32` buffers.
|
||||
fn old_combine(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
|
||||
let (ra, rb) = (a.read_region(area), b.read_region(area));
|
||||
let data: Vec<f32> = ra.iter().zip(&rb).map(|(x, y)| f(*x, *y)).collect();
|
||||
let mut out = Surface::new(PixelFormat::GRAY8);
|
||||
out.write_region(area, &data);
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_matches_the_per_pixel_version() {
|
||||
let area = Rect::new(0, 0, 1100, 700);
|
||||
let shapes = [
|
||||
Rect::from_xywh(40, 30, 1000, 620), // whole tiles inside
|
||||
Rect::from_xywh(3, 5, 1, 1),
|
||||
Rect::from_xywh(10, 10, 2, 7),
|
||||
Rect::from_xywh(100, 200, 37, 301),
|
||||
Rect::from_xywh(255, 255, 258, 2),
|
||||
Rect::from_xywh(-300, -200, 900, 600), // dragged past the canvas
|
||||
Rect::from_xywh(-500, -500, 2100, 1700), // larger than the canvas
|
||||
Rect::from_xywh(1090, 690, 40, 40),
|
||||
Rect::from_xywh(2000, 0, 50, 50), // entirely outside
|
||||
];
|
||||
for r in shapes {
|
||||
for aa in [true, false] {
|
||||
let new = ellipse_surface(r, area, aa);
|
||||
assert!(new == old_ellipse(r, area, aa), "ellipse {r:?} aa={aa}");
|
||||
}
|
||||
}
|
||||
// The other modes, against a rectangle and an anti-aliased ellipse with a partial edge.
|
||||
let mut rect = Surface::new(PixelFormat::GRAY8);
|
||||
rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]);
|
||||
let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true);
|
||||
let ops: [fn(f32, f32) -> f32; 3] = [|a, b| a.max(b), |a, b| a * (1.0 - b), |a, b| a.min(b)];
|
||||
for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell)] {
|
||||
for f in ops {
|
||||
assert!(combine_surfaces(a, b, area, f) == old_combine(a, b, area, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,8 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Combines `layers` (each `len` pixels) with statistic `s`.
|
||||
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
|
||||
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
|
||||
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
|
||||
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let mut out = vec![[0.0f32; 4]; len];
|
||||
@@ -97,7 +98,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
o[c] = stat(&mut samples[c], s).clamp(0.0, 1.0);
|
||||
o[c] = stat(&mut samples[c], s);
|
||||
}
|
||||
o[3] = alpha;
|
||||
}
|
||||
@@ -121,7 +122,7 @@ mod tests {
|
||||
assert_eq!(at(Stat::Minimum), 0.1);
|
||||
assert_eq!(at(Stat::Maximum), 0.9);
|
||||
assert!((at(Stat::Range) - 0.8).abs() < 1e-6);
|
||||
assert_eq!(at(Stat::Summation), 1.0); // clamped
|
||||
assert!((at(Stat::Summation) - 1.6).abs() < 1e-6);
|
||||
let var = at(Stat::Variance);
|
||||
assert!((at(Stat::StandardDeviation) - var.sqrt()).abs() < 1e-6);
|
||||
assert!(at(Stat::Skewness) > 0.5, "right-skewed");
|
||||
@@ -129,6 +130,31 @@ mod tests {
|
||||
assert!(at(Stat::Kurtosis) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statistics_preserve_hdr_and_signed_samples() {
|
||||
let layers = vec![px(-2.0), px(4.0)];
|
||||
for (mode, expected) in [
|
||||
(Stat::Mean, 1.0),
|
||||
(Stat::Median, 1.0),
|
||||
(Stat::Minimum, -2.0),
|
||||
(Stat::Maximum, 4.0),
|
||||
(Stat::Range, 6.0),
|
||||
(Stat::Summation, 2.0),
|
||||
(Stat::Variance, 9.0),
|
||||
(Stat::StandardDeviation, 3.0),
|
||||
] {
|
||||
assert_eq!(combine(&layers, mode)[0], [expected, expected, expected, 1.0], "{mode:?}");
|
||||
}
|
||||
for mode in [Stat::Mean, Stat::Median] {
|
||||
assert_eq!(combine(&[px(2.0), px(4.0)], mode)[0], [3.0, 3.0, 3.0, 1.0]);
|
||||
assert_eq!(combine(&[px(-2.0), px(-4.0)], mode)[0], [-3.0, -3.0, -3.0, 1.0]);
|
||||
}
|
||||
// These display-oriented statistics still apply their own normalisation.
|
||||
for mode in [Stat::Entropy, Stat::Skewness, Stat::Kurtosis] {
|
||||
assert!((0.0..=1.0).contains(&combine(&layers, mode)[0][0]), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_samples_are_ignored() {
|
||||
let layers = vec![vec![[0.5, 0.5, 0.5, 1.0]], vec![[1.0, 1.0, 1.0, 0.0]]];
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
mod extrude;
|
||||
mod wind;
|
||||
|
||||
use crate::fxutil::{MAXC, luma, native, ncol, subtractive, xy};
|
||||
use crate::image::Image;
|
||||
use crate::noise::hash01;
|
||||
@@ -86,6 +89,10 @@ pub(crate) fn extrude_reach(depth: f32) -> f32 {
|
||||
/// height, with shaded sides); Pyramids raise four shaded triangular faces.
|
||||
/// Heights are random or follow the cell's brightness.
|
||||
pub(crate) fn extrude(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
|
||||
extrude::blocks(src, out, ctx, spec).unwrap_or_else(|| extrude_direct(src, out, ctx, spec))
|
||||
}
|
||||
|
||||
fn extrude_direct(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
|
||||
let b = ctx.bounds;
|
||||
@@ -358,6 +365,10 @@ pub(crate) fn wind_reach(method: WindMethod) -> f32 {
|
||||
/// Wind: streaks trail downwind from edges (Blast: longer and denser);
|
||||
/// Stagger shifts random row segments along the wind.
|
||||
pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
|
||||
wind::streaks(src, out, ctx, method, from_right, seed).unwrap_or_else(|| wind_direct(src, out, ctx, method, from_right, seed))
|
||||
}
|
||||
|
||||
fn wind_direct(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let lmax = wind_reach(method) as i32;
|
||||
// Streaks move downwind: from the left they extend to the right (+x).
|
||||
@@ -406,3 +417,6 @@ pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_r
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Rasterize each swept block only where it can hit, in the original cell order.
|
||||
|
||||
use super::{ExtrudeSpec, extrude_reach, read_px};
|
||||
use crate::fxutil::{MAXC, luma, ncol, subtractive, unpremul_px};
|
||||
use crate::image::Image;
|
||||
use crate::noise::hash01;
|
||||
use crate::{Ctx, ExtrudeType};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
const MAX_PIXELS: usize = 4_194_304;
|
||||
const MAX_CELLS: i64 = 262_144;
|
||||
// Beyond this, f32 pixel centres can lose enough precision to widen the sweep bounds.
|
||||
const MAX_COORD: i32 = 1 << 22;
|
||||
|
||||
pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Option<Vec<f32>> {
|
||||
if spec.kind != ExtrudeType::Blocks || src.ch == 0 || src.ch > MAXC || !spec.size.is_finite() || !spec.depth.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let b = ctx.bounds;
|
||||
if [b.x0, b.y0, b.x1, b.y1, out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > MAX_COORD as u32) {
|
||||
return None;
|
||||
}
|
||||
let n = src.ch;
|
||||
let w = out.width() as usize;
|
||||
let count = w.checked_mul(out.height() as usize)?;
|
||||
if count > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut heights = Vec::new();
|
||||
heights.try_reserve_exact(count).ok()?;
|
||||
heights.resize(count, f32::NEG_INFINITY);
|
||||
let mut res = src.crop(out);
|
||||
let area = out.intersect(&b);
|
||||
if area.is_empty() {
|
||||
return Some(res);
|
||||
}
|
||||
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
|
||||
let reach = extrude_reach(spec.depth);
|
||||
let k = (reach / cs as f32).ceil() as i32 + 1;
|
||||
let (gx0, gy0) = ((area.x0 - b.x0).div_euclid(cs), (area.y0 - b.y0).div_euclid(cs));
|
||||
let (gx1, gy1) = ((area.x1 - 1 - b.x0).div_euclid(cs), (area.y1 - 1 - b.y0).div_euclid(cs));
|
||||
let (cx0, cy0) = ((gx0 - k).max(0), (gy0 - k).max(0));
|
||||
let (cx1, cy1) = ((gx1 + k).min((b.width() as i32 - 1) / cs), (gy1 + k).min((b.height() as i32 - 1) / cs));
|
||||
if i64::from(cx1 - cx0 + 1).checked_mul(i64::from(cy1 - cy0 + 1))? > MAX_CELLS {
|
||||
return None;
|
||||
}
|
||||
let (bcx, bcy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
|
||||
let half_diag = ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0);
|
||||
let half = cs as f32 / 2.0;
|
||||
let cc = ncol(ctx, n);
|
||||
let sub = subtractive(ctx);
|
||||
// Ascending gy, then gx, is the tie order of every pixel's old candidate search.
|
||||
for cy in cy0..=cy1 {
|
||||
for cx in cx0..=cx1 {
|
||||
let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs);
|
||||
let clipped = r.intersect(&b);
|
||||
if spec.mask_incomplete && clipped != r {
|
||||
continue;
|
||||
}
|
||||
let mut col = [0.0f32; MAXC];
|
||||
let mut cnt = 0.0;
|
||||
let step = (cs / 8).max(1) as usize;
|
||||
for yy in (clipped.y0..clipped.y1).step_by(step) {
|
||||
for xx in (clipped.x0..clipped.x1).step_by(step) {
|
||||
let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 };
|
||||
for (c, v) in col.iter_mut().enumerate().take(n) {
|
||||
let s = src.get(xx, yy, c);
|
||||
*v += if ctx.alpha && c < n - 1 { s * a } else { s };
|
||||
}
|
||||
cnt += 1.0;
|
||||
}
|
||||
}
|
||||
if cnt > 0.0 {
|
||||
for v in col.iter_mut().take(n) {
|
||||
*v /= cnt;
|
||||
}
|
||||
}
|
||||
unpremul_px(&mut col[..n], ctx.alpha);
|
||||
let h = if spec.level_based { luma(ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, spec.seed) };
|
||||
if !h.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half);
|
||||
let off = reach * h / half_diag;
|
||||
let (ox, oy) = ((mx - bcx) * off, (my - bcy) * off);
|
||||
// Two extra pixels conservatively cover rounding in the unchanged interval test.
|
||||
let sweep = Rect::new(
|
||||
(mx.min(mx + ox) - half).floor() as i32 - 2,
|
||||
(my.min(my + oy) - half).floor() as i32 - 2,
|
||||
(mx.max(mx + ox) + half).ceil() as i32 + 2,
|
||||
(my.max(my + oy) + half).ceil() as i32 + 2,
|
||||
)
|
||||
.intersect(&area);
|
||||
for y in sweep.y0..sweep.y1 {
|
||||
let gy = (y - b.y0).div_euclid(cs);
|
||||
if (cy - gy).abs() > k {
|
||||
continue;
|
||||
}
|
||||
let fy = y as f32 + 0.5;
|
||||
let Some(iy) = interval(fy, my, oy, half) else { continue };
|
||||
for x in sweep.x0..sweep.x1 {
|
||||
if (cx - (x - b.x0).div_euclid(cs)).abs() > k {
|
||||
continue;
|
||||
}
|
||||
let i = (y - out.y0) as usize * w + (x - out.x0) as usize;
|
||||
let height = heights.get_mut(i)?;
|
||||
if *height >= h {
|
||||
continue;
|
||||
}
|
||||
let fx = x as f32 + 0.5;
|
||||
let Some(ix) = interval(fx, mx, ox, half) else { continue };
|
||||
let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1));
|
||||
if lo > hi {
|
||||
continue;
|
||||
}
|
||||
*height = h;
|
||||
let face = if hi >= 1.0 {
|
||||
0
|
||||
} else if ix.1 < iy.1 {
|
||||
if ox > 0.0 { 1 } else { 2 }
|
||||
} else if oy > 0.0 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
};
|
||||
let px = res.get_mut(i * n..(i + 1) * n)?;
|
||||
if face == 0 && !spec.solid_front {
|
||||
let (sx, sy) = ((fx - (mx - bcx) * off).floor() as i32, (fy - (my - bcy) * off).floor() as i32);
|
||||
read_px(src, sx.clamp(r.x0, r.x1 - 1), sy.clamp(r.y0, r.y1 - 1), px);
|
||||
} else {
|
||||
let shade = match face {
|
||||
0 => 1.0,
|
||||
1 => 0.85,
|
||||
2 => 0.55,
|
||||
3 => 0.95,
|
||||
_ => 0.45,
|
||||
};
|
||||
for (v, c) in px.iter_mut().zip(&col).take(cc) {
|
||||
*v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0);
|
||||
}
|
||||
if ctx.alpha {
|
||||
*px.last_mut()? = col.get(n - 1).copied()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
fn interval(p: f32, m: f32, o: f32, half: f32) -> Option<(f32, f32)> {
|
||||
if o.abs() < 1e-6 {
|
||||
return ((p - m).abs() <= half).then_some((0.0, 1.0));
|
||||
}
|
||||
let (a, b) = ((p - m - half) / o, (p - m + half) / o);
|
||||
let (lo, hi) = (a.min(b).max(0.0), a.max(b).min(1.0));
|
||||
(lo <= hi).then_some((lo, hi))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use super::*;
|
||||
use crate::{FilterParams, apply_tiled};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn image(rect: Rect, n: usize, kind: usize) -> Image {
|
||||
let mut img = Image::new(rect, n);
|
||||
let mut s = 0x2545_f491u32;
|
||||
for (i, v) in img.data.iter_mut().enumerate() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = match kind {
|
||||
0 => (s % 256) as f32 / 255.0,
|
||||
1 => ((i / n) % 5) as f32 / 4.0,
|
||||
2 => 0.0,
|
||||
3 => (s % 65536) as f32 / 65535.0,
|
||||
_ => [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, -2.0, 3.5, 0.5][s as usize % 7],
|
||||
};
|
||||
}
|
||||
img
|
||||
}
|
||||
fn bits(v: Vec<f32>) -> Vec<u32> {
|
||||
v.into_iter().map(f32::to_bits).collect()
|
||||
}
|
||||
const MODELS: [ColorMode; 8] =
|
||||
[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel];
|
||||
|
||||
#[test]
|
||||
fn swept_blocks_match_the_pixel_search() {
|
||||
let bounds = Rect::new(-13, -9, 31, 26);
|
||||
for mode in MODELS {
|
||||
for alpha in [false, true] {
|
||||
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
for kind in 0..5 {
|
||||
let src = image(bounds.inflate(20), n, kind);
|
||||
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
|
||||
for flags in 0..8 {
|
||||
let spec = ExtrudeSpec {
|
||||
kind: ExtrudeType::Blocks,
|
||||
size,
|
||||
depth,
|
||||
level_based: flags & 1 != 0,
|
||||
solid_front: flags & 2 != 0,
|
||||
mask_incomplete: flags & 4 != 0,
|
||||
seed: 17,
|
||||
};
|
||||
let out = Rect::new(-17, -2, 24, 29);
|
||||
assert_eq!(
|
||||
bits(extrude(&src, out, &ctx, &spec)),
|
||||
bits(extrude_direct(&src, out, &ctx, &spec)),
|
||||
"{mode:?} alpha {alpha} kind {kind} size {size} depth {depth} flags {flags}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_scatter_matches_the_pixel_search() {
|
||||
let bounds = Rect::new(-17, -9, 39, 14);
|
||||
for mode in MODELS {
|
||||
for alpha in [false, true] {
|
||||
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
for kind in 0..5 {
|
||||
let src = image(bounds, n, kind);
|
||||
for method in [WindMethod::Wind, WindMethod::Blast, WindMethod::Stagger] {
|
||||
for right in [false, true] {
|
||||
for seed in [0, 1, 777, u32::MAX] {
|
||||
let out = bounds.inflate(3);
|
||||
assert_eq!(
|
||||
bits(wind(&src, out, &ctx, method, right, seed)),
|
||||
bits(wind_direct(&src, out, &ctx, method, right, seed)),
|
||||
"{mode:?} alpha {alpha} kind {kind} {method:?} right {right} seed {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_and_partial_windows_match() {
|
||||
for bounds in [Rect::new(0, 0, 1, 1), Rect::new(-5, -6, -4, 31), Rect::new(3, 8, 43, 9)] {
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
for src in [image(bounds, 4, 0), image(bounds.inflate(2), 4, 1)] {
|
||||
let out = bounds.inflate(2);
|
||||
for right in [false, true] {
|
||||
assert_eq!(bits(wind(&src, out, &ctx, WindMethod::Blast, right, 17)), bits(wind_direct(&src, out, &ctx, WindMethod::Blast, right, 17)));
|
||||
}
|
||||
for kind in [ExtrudeType::Blocks, ExtrudeType::Pyramids] {
|
||||
let spec = ExtrudeSpec { kind, size: 8.0, depth: 255.0, level_based: true, solid_front: false, mask_incomplete: false, seed: 17 };
|
||||
assert_eq!(bits(extrude(&src, out, &ctx, &spec)), bits(extrude_direct(&src, out, &ctx, &spec)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_bounds_cover_float_rounding_and_ties() {
|
||||
for offset in [0, -(1 << 22), (1 << 22) - 80] {
|
||||
let bounds = Rect::new(offset, offset, offset + 64, offset + 49);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
let src = image(bounds, 4, 1);
|
||||
for size in [2.0, 7.0, 19.0] {
|
||||
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, depth: 255.0, level_based: true, solid_front: true, mask_incomplete: false, seed: 42 };
|
||||
assert!(extrude::blocks(&src, bounds, &ctx, &spec).is_some());
|
||||
assert_eq!(bits(extrude(&src, bounds, &ctx, &spec)), bits(extrude_direct(&src, bounds, &ctx, &spec)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_scratch_rejects_large_and_unsupported_geometry() {
|
||||
let bounds = Rect::new(0, 0, 1, 1);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
let src = image(bounds, 4, 0);
|
||||
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size: 2.0, depth: 255.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 42 };
|
||||
assert!(extrude::blocks(&src, Rect::new(0, 0, 4096, 4096), &ctx, &spec).is_none());
|
||||
assert!(wind::streaks(&src, Rect::new(0, 0, 1_048_577, 1), &ctx, WindMethod::Blast, false, 0).is_none());
|
||||
let far = Ctx { bounds: Rect::new(10_000_000, 0, 10_000_010, 10), ..ctx };
|
||||
assert!(extrude::blocks(&src, bounds, &far, &spec).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_selection_and_tile_sizes_match_original_kernels() {
|
||||
let bounds = Rect::new(-11, -7, 32, 24);
|
||||
let area = Rect::new(-9, -5, 29, 21);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(mode, sample, true);
|
||||
let ctx = Ctx { bounds, mode, alpha: true };
|
||||
let img = image(bounds, fmt.channels(), 3);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.write_region(bounds, &img.data);
|
||||
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
|
||||
let mask: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize).map(|i| (i % 5) as f32 / 4.0).collect();
|
||||
selection.write_region(bounds, &mask);
|
||||
let cases = [
|
||||
FilterParams::Extrude {
|
||||
kind: ExtrudeType::Blocks,
|
||||
size: 7.0,
|
||||
depth: 170.0,
|
||||
level_based: true,
|
||||
solid_front: false,
|
||||
mask_incomplete: false,
|
||||
seed: 17,
|
||||
},
|
||||
FilterParams::Wind { method: WindMethod::Blast, from_right: true, seed: 17 },
|
||||
];
|
||||
for p in cases {
|
||||
let halo = match p {
|
||||
FilterParams::Extrude { .. } => 70,
|
||||
_ => 40,
|
||||
};
|
||||
let src = Image::read_clamped(&s, area.inflate(halo), bounds);
|
||||
let mut raw = match p {
|
||||
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => {
|
||||
extrude_direct(&src, area, &ctx, &ExtrudeSpec { kind, size, depth, level_based, solid_front, mask_incomplete, seed })
|
||||
}
|
||||
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
|
||||
_ => continue,
|
||||
};
|
||||
let original = s.read_region(area);
|
||||
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
|
||||
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
|
||||
let k = selection.sample_channel(x, y, 0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
|
||||
}
|
||||
}
|
||||
let mut want = s.clone();
|
||||
want.write_region(area, &raw);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
let got = apply_tiled(&s, &p, area, bounds, Some(&selection), tile, Some(bounds));
|
||||
assert_eq!(bits(got.read_region(bounds)), bits(want.read_region(bounds)), "{mode:?} {sample:?} {} tile {tile}", p.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Scatter each source streak once, retaining the old nearest-source tie rule.
|
||||
|
||||
use super::{read_px, wind_reach};
|
||||
use crate::fxutil::{MAXC, luma};
|
||||
use crate::image::Image;
|
||||
use crate::noise::hash01;
|
||||
use crate::{Ctx, WindMethod};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
const MAX_ROW: usize = 1_048_576;
|
||||
|
||||
pub(super) fn streaks(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Option<Vec<f32>> {
|
||||
if method == WindMethod::Stagger || src.ch == 0 || src.ch > MAXC {
|
||||
return None;
|
||||
}
|
||||
let n = src.ch;
|
||||
let w = out.width() as usize;
|
||||
if w == 0 || w > MAX_ROW || out.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lmax = wind_reach(method) as i32;
|
||||
let d = if from_right { -1 } else { 1 };
|
||||
// Include the upstream halo and one more source for the edge difference.
|
||||
let start = if from_right { out.x1.checked_add(lmax - 1)? } else { out.x0.checked_sub(lmax)? };
|
||||
start.checked_sub(d)?;
|
||||
let sources = w.checked_add(lmax as usize)?;
|
||||
let mut best = Vec::new();
|
||||
best.try_reserve_exact(w).ok()?;
|
||||
best.resize(w, (0.0f32, lmax + 1));
|
||||
let mut res = src.crop(out);
|
||||
let mut tmp = [0.0f32; MAXC];
|
||||
let strength = if method == WindMethod::Blast { 8.0 } else { 4.0 };
|
||||
for y in out.y0..out.y1 {
|
||||
best.fill((0.0, lmax + 1));
|
||||
read_px(src, start - d, y, &mut tmp[..n]);
|
||||
let mut prev = luma(ctx, &tmp[..n]);
|
||||
for step in 0..sources {
|
||||
let s = i32::try_from(i64::from(start) + i64::from(d) * i64::try_from(step).ok()?).ok()?;
|
||||
read_px(src, s, y, &mut tmp[..n]);
|
||||
let lum = luma(ctx, &tmp[..n]);
|
||||
let edge = ((lum - prev).abs() * strength).min(1.0);
|
||||
prev = lum;
|
||||
if edge <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let len = (hash01(s, y, 44, seed).powi(2) * lmax as f32).max(1.0);
|
||||
for k in 1..=(len.floor() as i32).min(lmax) {
|
||||
let x = s.checked_add(d * k)?;
|
||||
if x < out.x0 || x >= out.x1 {
|
||||
continue;
|
||||
}
|
||||
let weight = edge * (1.0 - k as f32 / (len + 1.0));
|
||||
let hit = best.get_mut((x - out.x0) as usize)?;
|
||||
// Sources are visited downwind; equal weights prefer the smallest original k.
|
||||
if weight > hit.0 || (weight == hit.0 && k < hit.1) {
|
||||
*hit = (weight, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
let row = (y - out.y0) as usize * w * n;
|
||||
for (x, &(weight, k)) in best.iter().enumerate() {
|
||||
if weight > 0.0 {
|
||||
let sx = i32::try_from(i64::from(out.x0) + x as i64 - i64::from(d * k)).ok()?;
|
||||
read_px(src, sx, y, &mut tmp[..n]);
|
||||
let px = res.get_mut(row + x * n..row + (x + 1) * n)?;
|
||||
for (v, s) in px.iter_mut().zip(&tmp) {
|
||||
*v += (*s - *v) * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
|
||||
#[path = "tests_tilt_shift_reference.rs"]
|
||||
mod reference;
|
||||
|
||||
fn image(rect: Rect, ch: usize) -> Image {
|
||||
let data = (rect.y0..rect.y1)
|
||||
.flat_map(|y| {
|
||||
(rect.x0..rect.x1).flat_map(move |x| {
|
||||
(0..ch).map(move |c| match c {
|
||||
0 => (x * 17 + y * 31).rem_euclid(113) as f32 / 37.0 - 0.5,
|
||||
1 => {
|
||||
if (x + y).rem_euclid(3) == 0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => ((x ^ y).unsigned_abs() as usize + c) as f32 % 23.0 / 22.0,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Image { rect, ch, data }
|
||||
}
|
||||
|
||||
fn params(blur: f32, centre: (f32, f32), angle: f32, focus: f32, transition: f32) -> crate::FilterParams {
|
||||
crate::FilterParams::TiltShift { blur, center_x: centre.0, center_y: centre.1, angle, focus, transition }
|
||||
}
|
||||
|
||||
fn check(src: &Image, out: Rect, ctx: &Ctx, p: &crate::FilterParams) {
|
||||
let crate::FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } = *p else { panic!("Tilt-Shift fixture") };
|
||||
let want = reference::tilt_shift(src, out, ctx, blur, (center_x, center_y), angle, focus, transition);
|
||||
let got = tilt_shift(src, out, ctx, blur, (center_x, center_y), angle, focus, transition);
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (i, (&got, &want)) in got.iter().zip(&want).enumerate() {
|
||||
assert!(got.to_bits() == want.to_bits() || (got.is_nan() && want.is_nan()), "sample {i}: {got} != {want}, delta {}", (got - want).abs());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() {
|
||||
let bounds = Rect::new(-19, 7, 54, 66);
|
||||
let mut cases = Vec::new();
|
||||
for blur in [0.0, 0.49, 0.5, 1.0, 15.0, 80.0, 500.0] {
|
||||
for angle in [-90.0, -71.0, 0.0, 27.0, 90.0, 180.0] {
|
||||
cases.push(params(blur, (0.18, 0.91), angle, 0.03, 0.01));
|
||||
cases.push(params(blur, (0.5, 0.5), angle, 0.1, 0.6));
|
||||
}
|
||||
}
|
||||
cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1));
|
||||
cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15));
|
||||
for ch in 1..=6 {
|
||||
let src = image(bounds.inflate(12), ch);
|
||||
for alpha in [false, true] {
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
|
||||
for p in &cases {
|
||||
check(&src, bounds, &ctx, p);
|
||||
check(&src, Rect::new(-7, 17, 15, 39), &ctx, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_shift_preserves_degenerate_and_nonfinite_parameter_behavior() {
|
||||
for bounds in [Rect::EMPTY, Rect::new(1, -1, 2, 0), Rect::new(-5, 3, 14, 4)] {
|
||||
let src = image(bounds, 4);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
for value in [0.0, -1.0, f32::MIN, f32::MAX, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
for field in 0..6 {
|
||||
// The old NaN blur amount panicked in the sigma clamp. Its
|
||||
// corrected identity behavior has a separate regression below.
|
||||
if field == 0 && value.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let mut p = params(15.0, (0.5, 0.5), 0.0, 0.1, 0.15);
|
||||
if let crate::FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } = &mut p {
|
||||
match field {
|
||||
0 => *blur = value,
|
||||
1 => *center_x = value,
|
||||
2 => *center_y = value,
|
||||
3 => *angle = value,
|
||||
4 => *focus = value,
|
||||
_ => *transition = value,
|
||||
}
|
||||
}
|
||||
check(&src, bounds, &ctx, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let bounds = Rect::new(0, 0, 3, 5);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
assert!(crate::kernel(¶ms(15.0, (0.5, 0.5), 0.0, 0.1, 0.15), &Image { rect: bounds, ch: 4, data: vec![] }, bounds, &ctx).is_empty());
|
||||
assert!(crate::kernel(¶ms(15.0, (0.5, 0.5), 0.0, 0.1, 0.15), &Image { rect: bounds, ch: 0, data: vec![] }, bounds, &ctx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_shift_nan_blur_is_an_identity_instead_of_panicking() {
|
||||
let bounds = Rect::new(-2, 3, 9, 14);
|
||||
let src = image(bounds, 4);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
let p = params(f32::NAN, (0.5, 0.5), 0.0, 0.1, 0.15);
|
||||
assert_eq!(crate::kernel(&p, &src, bounds, &ctx), src.data);
|
||||
let mut surface = Surface::new(PixelFormat::RGBA32F);
|
||||
surface.write_region(bounds, &src.data);
|
||||
assert_eq!(crate::apply_in(&surface, &p, bounds, bounds, None, bounds), surface);
|
||||
// Prove this synthetic input panicked in the unchanged starting kernel.
|
||||
assert!(std::panic::catch_unwind(|| reference::tilt_shift(&src, bounds, &ctx, f32::NAN, (0.5, 0.5), 0.0, 0.1, 0.15)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_shift_keeps_running_sum_order_for_extreme_hdr_and_signed_zero() {
|
||||
let bounds = Rect::new(-13, -5, 24, 28);
|
||||
let mut src = image(bounds.inflate(15), 4);
|
||||
let values = [0.0, -0.0, 1e30, -1e30, 1e-30, -1e-30, 0.8];
|
||||
for (i, px) in src.data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
px[0] = values[i % values.len()];
|
||||
px[1] = values[(i + 3) % values.len()];
|
||||
px[3] = [0.0, 1e-8, 0.5, 1.0][i % 4];
|
||||
}
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
for blur in [0.5, 15.0, 80.0, 300.0] {
|
||||
for angle in [0.0, 37.0, -90.0] {
|
||||
let p = params(blur, (0.5, 0.5), angle, 0.1, 0.15);
|
||||
check(&src, bounds, &ctx, &p);
|
||||
check(&src, Rect::new(0, 0, 10, 17), &ctx, &p);
|
||||
}
|
||||
}
|
||||
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
let mut invalid = src.clone();
|
||||
invalid.data[19] = value;
|
||||
invalid.data[47] = value;
|
||||
check(&invalid, bounds, &ctx, ¶ms(15.0, (0.5, 0.5), 0.0, 0.1, 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
|
||||
let bounds = Rect::new(-17, 11, 96, 84);
|
||||
let area = Rect::new(-9, 17, 79, 77);
|
||||
let p = params(17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
|
||||
let mut selection = Surface::new(PixelFormat::GRAY8);
|
||||
selection.fill_rect(bounds, &[0.4]);
|
||||
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
|
||||
selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]);
|
||||
for mode in [
|
||||
ColorMode::Rgb,
|
||||
ColorMode::Grayscale,
|
||||
ColorMode::Cmyk,
|
||||
ColorMode::Lab,
|
||||
ColorMode::Bitmap,
|
||||
ColorMode::Indexed,
|
||||
ColorMode::Duotone,
|
||||
ColorMode::Multichannel,
|
||||
] {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for alpha in [false, true] {
|
||||
let fmt = PixelFormat::new(mode, sample, alpha);
|
||||
let mut surface = Surface::new(fmt);
|
||||
surface.write_region(bounds, &image(bounds, fmt.channels()).data);
|
||||
let original = surface.clone();
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
let radius = reach(17.0).ceil() as i32 + 1;
|
||||
assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius));
|
||||
for extent in [None, Some(bounds)] {
|
||||
for tile in [16, 47, 128] {
|
||||
let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent);
|
||||
let mut want = surface.clone();
|
||||
for y in (area.y0..area.y1).step_by(tile as usize) {
|
||||
for x in (area.x0..area.x1).step_by(tile as usize) {
|
||||
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
|
||||
let src = match extent {
|
||||
Some(e) => Image::read_clamped(&surface, t.inflate(radius), e),
|
||||
None => Image::read(&surface, t.inflate(radius)),
|
||||
};
|
||||
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
|
||||
crate::mix_selection(&mut data, t, &selection, &src);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
assert_eq!(got.read_region(bounds), want.read_region(bounds), "{fmt:?}, tile {tile}, extent {extent:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(surface, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_shift_cancellation_preserves_source() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let bounds = Rect::new(0, 0, 512, 256);
|
||||
let mut source = Surface::new(PixelFormat::RGBA8);
|
||||
source.write_region(bounds, &image(bounds, 4).data);
|
||||
let original = source.clone();
|
||||
let p = params(80.0, (0.5, 0.5), 27.0, 0.1, 0.15);
|
||||
assert!(crate::apply_in_with(&source, &p, bounds, bounds, None, bounds, &Interrupt::cancel_only(&|| true)).is_none());
|
||||
let cancelled = AtomicBool::new(false);
|
||||
let cancel = || cancelled.load(Ordering::Relaxed);
|
||||
let progress = |p| {
|
||||
if p > 0.0 {
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
let ctl = Interrupt::new(&cancel, &progress);
|
||||
assert!(crate::apply_in_with(&source, &p, bounds, bounds, None, bounds, &ctl).is_none());
|
||||
assert!(cancelled.load(Ordering::Relaxed));
|
||||
assert_eq!(source, original);
|
||||
let fresh = crate::apply_in_with(&source, &p, bounds, bounds, None, bounds, &Interrupt::NONE);
|
||||
assert_eq!(fresh, Some(crate::apply_in(&source, &p, bounds, bounds, None, bounds)));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Frozen Tilt-Shift kernel from base a28d786638ffbb84c5fb7d5d30ea1c3d594cd3bc.
|
||||
use super::*;
|
||||
/// Gaussian σ for a gallery blur amount in px.
|
||||
fn sigma_of(blur: f32) -> f32 {
|
||||
blur.clamp(0.0, 500.0) * 0.6
|
||||
}
|
||||
|
||||
const LEVELS: usize = 6;
|
||||
|
||||
/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`).
|
||||
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
if n == 0 || out.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if smax < 0.3 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let win = src.rect;
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let Some(row_len) = ww.checked_mul(n).filter(|&len| len > 0) else { return Vec::new() };
|
||||
let Some(source_len) = row_len.checked_mul(wh) else { return Vec::new() };
|
||||
let Some(pixels) = ow.checked_mul(oh) else { return Vec::new() };
|
||||
let Some(samples) = pixels.checked_mul(n) else { return Vec::new() };
|
||||
if trim_levels && src.data.len() != source_len {
|
||||
return Vec::new();
|
||||
}
|
||||
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
|
||||
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
|
||||
let sig: Vec<f32> = (0..pixels)
|
||||
.map(|i| {
|
||||
let (x, y) = xy(out, i);
|
||||
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
|
||||
})
|
||||
.collect();
|
||||
// Hat-function weight of level k for σ s.
|
||||
let weight = |k: usize, s: f32| -> f32 {
|
||||
let c = lv[k];
|
||||
if k > 0 && s >= lv[k - 1] && s <= c {
|
||||
(s - lv[k - 1]) / (c - lv[k - 1]).max(1e-6)
|
||||
} else if k + 1 < lv.len() && s >= c && s <= lv[k + 1] {
|
||||
(lv[k + 1] - s) / (lv[k + 1] - c).max(1e-6)
|
||||
} else if k + 1 == lv.len() && s >= c {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
let p = if trim_levels {
|
||||
// The window is exactly src.rect: read interleaved channels together,
|
||||
// retaining the same multiplication and alpha order as premul_window.
|
||||
let mut p = src.data.clone();
|
||||
premultiply(&mut p, n, ctx.alpha);
|
||||
p
|
||||
} else {
|
||||
premul_window(src, win, ctx.alpha)
|
||||
};
|
||||
let mut acc = vec![0.0f32; samples];
|
||||
let mut buf = Vec::new();
|
||||
for (k, &lvk) in lv.iter().enumerate() {
|
||||
if !sig.iter().any(|&s| weight(k, s) > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let (level, lw): (&[f32], usize) = if k == 0 {
|
||||
(&p, ww)
|
||||
} else {
|
||||
// Only Iris opts in. Each level needs its own box-cascade reach,
|
||||
// not the maximum blur's trailing halo. Keep the original top/left:
|
||||
// moving the running sums' start changes floating-point cancellation.
|
||||
let trim = trim_levels && win.intersect(&out) == out;
|
||||
let reach = if !trim || lvk < 0.3 { 0 } else { gauss_box_radii(lvk).iter().sum::<usize>() };
|
||||
let reach = i32::try_from(reach).unwrap_or(i32::MAX);
|
||||
let lw = if trim { (i64::from(out.x1.saturating_add(reach).min(win.x1)) - i64::from(win.x0)) as usize } else { ww };
|
||||
let lh = if trim { (i64::from(out.y1.saturating_add(reach).min(win.y1)) - i64::from(win.y0)) as usize } else { wh };
|
||||
buf.clear();
|
||||
if buf.capacity() == 0 {
|
||||
// Match the old single full-window allocation. Row appends must
|
||||
// not geometrically grow the capacity beyond that window.
|
||||
buf.reserve_exact(p.len());
|
||||
}
|
||||
if trim {
|
||||
for row in p.chunks_exact(row_len).take(lh) {
|
||||
if let Some(row) = row.get(..lw * n) {
|
||||
buf.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buf.extend_from_slice(&p);
|
||||
}
|
||||
gauss_blur_n(&mut buf, lw, lh, n, lvk);
|
||||
(&buf, lw)
|
||||
};
|
||||
for (i, &s) in sig.iter().enumerate() {
|
||||
let wk = weight(k, s);
|
||||
if wk <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = xy(out, i);
|
||||
let o = (((y - win.y0) as usize) * lw + (x - win.x0) as usize) * n;
|
||||
if let (Some(dst), Some(px)) = (acc.get_mut(i * n..(i + 1) * n), level.get(o..o + n)) {
|
||||
for (dst, px) in dst.iter_mut().zip(px) {
|
||||
*dst += px * wk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for px in acc.chunks_exact_mut(n) {
|
||||
unpremul_px(px, ctx.alpha);
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
fn short_side(b: Rect) -> f32 {
|
||||
(b.width().min(b.height()) as f32).max(1.0)
|
||||
}
|
||||
|
||||
/// Tilt-Shift: a sharp band through the centre at `angle`, blurring with
|
||||
/// distance beyond `focus` over `transition` (both fractions of the shorter side).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: (f32, f32), angle: f32, focus: f32, transition: f32) -> Vec<f32> {
|
||||
let b = ctx.bounds;
|
||||
let ss = short_side(b);
|
||||
let (cx, cy) = (b.x0 as f32 + centre.0 * b.width() as f32, b.y0 as f32 + centre.1 * b.height() as f32);
|
||||
// Band normal (the band runs along `angle`).
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
let (nx, ny) = (s, c);
|
||||
let smax = sigma_of(blur);
|
||||
let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3));
|
||||
variable_blur(src, out, ctx, smax, false, |x, y| {
|
||||
let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss;
|
||||
smax * smoothstep(f0, f1, d)
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,22 @@ pub struct Homography(pub [f64; 9]);
|
||||
impl Homography {
|
||||
pub const IDENTITY: Homography = Homography([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
pub fn is_identity(&self) -> bool {
|
||||
let m = &self.0;
|
||||
if m[8].abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let w = m[8];
|
||||
((m[0] / w) - 1.0).abs() < 1e-9
|
||||
&& ((m[4] / w) - 1.0).abs() < 1e-9
|
||||
&& (m[1] / w).abs() < 1e-9
|
||||
&& (m[2] / w).abs() < 1e-9
|
||||
&& (m[3] / w).abs() < 1e-9
|
||||
&& (m[5] / w).abs() < 1e-9
|
||||
&& (m[6] / w).abs() < 1e-9
|
||||
&& (m[7] / w).abs() < 1e-9
|
||||
}
|
||||
|
||||
pub fn apply(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
let m = &self.0;
|
||||
let w = m[6] * x + m[7] * y + m[8];
|
||||
@@ -127,6 +143,56 @@ fn catmull_rom(t: f64) -> [f64; 4] {
|
||||
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
|
||||
/// the warped quad are transparent.
|
||||
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
warp(src, src_rect, h, interp, None)
|
||||
}
|
||||
|
||||
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
|
||||
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
|
||||
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
|
||||
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
warp(src, src_rect, h, interp, Some(clip))
|
||||
}
|
||||
|
||||
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
|
||||
/// corner angle above ~0.06°, wound clockwise on screen (y down) and at least 1 px² in area?
|
||||
pub fn convex_quad(q: &[[f64; 2]; 4]) -> bool {
|
||||
if q.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let mut area2 = 0.0;
|
||||
for i in 0..4 {
|
||||
let (a, b, c) = (q[i], q[(i + 1) % 4], q[(i + 2) % 4]);
|
||||
let (e1, e2) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
|
||||
let cross = e1[0] * e2[1] - e1[1] * e2[0];
|
||||
// sin(turn) = cross / (|e1| |e2|): positive (clockwise on screen) and not a straight angle.
|
||||
if cross.is_nan() || cross <= 1e-3 * e1[0].hypot(e1[1]) * e2[0].hypot(e2[1]) {
|
||||
return false;
|
||||
}
|
||||
area2 += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
area2.is_finite() && area2 >= 2.0
|
||||
}
|
||||
|
||||
/// The output size a Perspective Crop of `q` (TL, TR, BR, BL) takes when none is given: the mean
|
||||
/// length of the top and bottom edges by the mean length of the left and right edges.
|
||||
pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) {
|
||||
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let [tl, tr, br, bl] = *q;
|
||||
((len(tl, tr) + len(bl, br)) / 2.0, (len(tl, bl) + len(tr, br)) / 2.0)
|
||||
}
|
||||
|
||||
/// The homography taking the quad `q` (TL, TR, BR, BL) onto the rectangle (0, 0)–(`w`, `h`), corner
|
||||
/// to corner: the inverse of the unit-square-to-quad map (Heckbert, "Fundamentals of Texture
|
||||
/// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a
|
||||
/// [`convex_quad`] or the size isn't positive.
|
||||
pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
|
||||
if !(convex_quad(q) && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
|
||||
}
|
||||
|
||||
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
let mut fmt = src.format();
|
||||
let converted;
|
||||
let mut src = if fmt.alpha {
|
||||
@@ -137,13 +203,17 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
||||
&converted
|
||||
};
|
||||
let mut out = Surface::new(fmt);
|
||||
if src_rect.is_empty() {
|
||||
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut h = *h;
|
||||
let mut src_rect = src_rect;
|
||||
// Pre-reduce for large downscales (bicubic alone aliases below ~50%).
|
||||
let (cx, cy) = ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0);
|
||||
// Pre-reduce for large downscales (bicubic alone aliases below ~50%). With a clip, the scale
|
||||
// that matters is the one at the source point landing in the clip's centre.
|
||||
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
|
||||
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
|
||||
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
|
||||
};
|
||||
let scale = h.local_scale(cx, cy);
|
||||
let reduced;
|
||||
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
|
||||
@@ -157,15 +227,32 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
||||
// Destination bounds: the warped corners (plus a pixel for filter support).
|
||||
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
|
||||
.map(|(x, y)| h.apply(x as f64, y as f64));
|
||||
if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) {
|
||||
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
|
||||
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
|
||||
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
|
||||
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
|
||||
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
|
||||
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
|
||||
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
|
||||
let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite());
|
||||
let lim = 1 << 20;
|
||||
let bounds = || {
|
||||
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
|
||||
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
|
||||
Rect::new(bx0, by0, bx1, by1)
|
||||
};
|
||||
let dst = match clip {
|
||||
// The warped corners only bound the result while the whole source is in front of the horizon.
|
||||
Some(c) if finite && side.is_some() => bounds().intersect(&c),
|
||||
Some(c) => c,
|
||||
None if finite => bounds(),
|
||||
None => return Ok(out),
|
||||
};
|
||||
if dst.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let lim = 1 << 20;
|
||||
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
|
||||
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
|
||||
let dst = Rect::new(bx0, by0, bx1, by1);
|
||||
let n = fmt.channels();
|
||||
let a = n - 1;
|
||||
let tile_count = dst.tiles().count();
|
||||
@@ -174,13 +261,6 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
||||
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
|
||||
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
|
||||
let src_ref: &Surface = src;
|
||||
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
|
||||
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
|
||||
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
|
||||
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
|
||||
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
|
||||
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
|
||||
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
|
||||
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
|
||||
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
|
||||
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
|
||||
@@ -327,6 +407,30 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
|
||||
/// self-crossing, counter-clockwise and non-finite quads have none.
|
||||
#[test]
|
||||
fn quad_to_rect_maps_corners_and_refuses_bad_quads() {
|
||||
let q = [[30.0, 12.0], [140.0, 25.0], [128.0, 110.0], [14.0, 95.0]];
|
||||
let h = quad_to_rect(&q, 100.0, 80.0).unwrap();
|
||||
for (c, want) in q.iter().zip([[0.0, 0.0], [100.0, 0.0], [100.0, 80.0], [0.0, 80.0]]) {
|
||||
let (x, y) = h.apply(c[0], c[1]);
|
||||
assert!((x - want[0]).abs() < 1e-9 && (y - want[1]).abs() < 1e-9, "{c:?} -> ({x}, {y})");
|
||||
}
|
||||
let (w, ht) = quad_size(&q);
|
||||
assert!((w - 112.874).abs() < 1e-3 && (ht - 85.185).abs() < 1e-3, "{w} x {ht}");
|
||||
for bad in [
|
||||
[[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 50.0]],
|
||||
[[0.0, 0.0], [50.0, 50.0], [50.0, 0.0], [0.0, 50.0]],
|
||||
[[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0]],
|
||||
[[0.0, 0.0], [f64::NAN, 0.0], [50.0, 50.0], [0.0, 50.0]],
|
||||
[[0.0, 0.0]; 4],
|
||||
] {
|
||||
assert!(quad_to_rect(&bad, 10.0, 10.0).is_none(), "{bad:?}");
|
||||
}
|
||||
assert!(quad_to_rect(&q, 0.0, 10.0).is_none());
|
||||
}
|
||||
|
||||
fn rgba() -> Surface {
|
||||
Surface::new(PixelFormat::RGBA8)
|
||||
}
|
||||
@@ -427,4 +531,12 @@ mod tests {
|
||||
assert_eq!(o.pixel(2, 2)[3], 0.0);
|
||||
assert_eq!(o.pixel(7, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homography_is_identity() {
|
||||
assert!(Homography::IDENTITY.is_identity());
|
||||
assert!(Homography([2.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0]).is_identity());
|
||||
assert!(!Homography([1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_identity());
|
||||
assert!(!Homography([1.0, 0.1, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_identity());
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,82 @@
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Separable grayscale dilation (max filter) of one interleaved channel, radius `r` px.
|
||||
/// Prefix/suffix block maxima, with clipped edge windows and the original zero floor.
|
||||
/// One comparison per sample in each direction, independent of the trap width.
|
||||
fn max_line(out: &mut [f32], radius: usize, get: impl Fn(usize) -> f32) {
|
||||
let len = out.len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let r = radius.min(len - 1);
|
||||
let mut suffix = Vec::new();
|
||||
if r <= 2 || suffix.try_reserve_exact(len).is_err() {
|
||||
for (x, v) in out.iter_mut().enumerate() {
|
||||
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
suffix.resize(len, 0.0f32);
|
||||
let block = r.saturating_mul(2).saturating_add(1);
|
||||
let mut acc = 0.0f32;
|
||||
let mut negative_zero = false;
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
if i % block == 0 {
|
||||
acc = 0.0;
|
||||
}
|
||||
let value = get(i);
|
||||
negative_zero |= value.to_bits() == (-0.0f32).to_bits();
|
||||
acc = acc.max(value);
|
||||
*v = acc;
|
||||
}
|
||||
// f32::max may choose either sign of equal zero depending on operand order.
|
||||
// Preserve the original left-to-right fold for these uncommon float lines.
|
||||
if negative_zero {
|
||||
for (x, v) in out.iter_mut().enumerate() {
|
||||
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
acc = 0.0;
|
||||
for (i, v) in suffix.iter_mut().enumerate().rev() {
|
||||
if i == len - 1 || (i + 1) % block == 0 {
|
||||
acc = 0.0;
|
||||
}
|
||||
acc = acc.max(get(i));
|
||||
*v = acc;
|
||||
}
|
||||
for x in 0..len {
|
||||
let (lo, hi) = (x.saturating_sub(r), x.saturating_add(r).min(len - 1));
|
||||
let value = if lo == 0 {
|
||||
out.get(hi).copied().unwrap_or(0.0)
|
||||
} else if hi == len - 1 && lo / block == hi / block {
|
||||
suffix.get(lo).copied().unwrap_or(0.0)
|
||||
} else {
|
||||
suffix.get(lo).copied().unwrap_or(0.0).max(out.get(hi).copied().unwrap_or(0.0))
|
||||
};
|
||||
if let Some(v) = out.get_mut(x) {
|
||||
*v = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel's square dilation, transposed so each vertical scan writes a contiguous row.
|
||||
fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
|
||||
if r <= 2 {
|
||||
return dilate_channel_direct(src, w, h, ch, c, r);
|
||||
}
|
||||
let mut tmp = vec![0.0f32; w * h];
|
||||
par_rows(&mut tmp, w, 1, |y, row| {
|
||||
max_line(row, r, |x| src.get((y * w + x) * ch + c).copied().unwrap_or(0.0));
|
||||
});
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
par_rows(&mut out, h, 1, |x, column| {
|
||||
max_line(column, r, |y| tmp.get(y * w + x).copied().unwrap_or(0.0));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn dilate_channel_direct(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
|
||||
let get = |buf: &[f32], x: usize, y: usize| buf[(y * w + x) * ch + c];
|
||||
// Horizontal pass.
|
||||
let mut tmp = vec![0.0f32; w * h];
|
||||
@@ -44,14 +118,14 @@ fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize
|
||||
/// spread; any alpha channel (`ch > 4`) is untouched. A pixel keeps its original ink where it has
|
||||
/// no ink at all (paper), so only internal colour edges gain a trap band.
|
||||
pub fn trap(px: &mut [f32], w: usize, h: usize, ch: usize, width: usize) {
|
||||
if width == 0 || w == 0 || h == 0 || ch < 4 {
|
||||
if width == 0 || w == 0 || h == 0 || ch < 4 || w.checked_mul(h).and_then(|n| n.checked_mul(ch)) != Some(px.len()) {
|
||||
return;
|
||||
}
|
||||
let inks = 4.min(ch);
|
||||
let dil: Vec<Vec<f32>> = (0..inks).map(|c| dilate_channel(px, w, h, ch, c, width)).collect();
|
||||
par_rows(px, w, ch, |y, row| {
|
||||
for (x, pixel) in row.chunks_exact_mut(ch).enumerate() {
|
||||
let i = y * w + x;
|
||||
let i = if width <= 2 { y * w + x } else { x * h + y };
|
||||
// Printed where any ink is present.
|
||||
let inked = (0..inks).any(|c| pixel[c] > 1e-4);
|
||||
if !inked {
|
||||
@@ -123,3 +197,66 @@ mod tests {
|
||||
assert_eq!(px, orig);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod linear_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn all_clipped_windows_match_the_direct_maximum() {
|
||||
let mut seed = 1234567u32;
|
||||
for len in 1..90 {
|
||||
let values: Vec<f32> = (0..len)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
[f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, -2.0, 0.25, 1.0, 2.0][seed as usize % 9]
|
||||
})
|
||||
.collect();
|
||||
for radius in [0, 1, 2, 3, 5, 16, 50, 128, usize::MAX] {
|
||||
let mut out = vec![0.0; len];
|
||||
max_line(&mut out, radius, |i| values[i]);
|
||||
for (x, v) in out.iter().enumerate() {
|
||||
let want = (x.saturating_sub(radius)..=x.saturating_add(radius).min(len - 1)).fold(0.0f32, |m, i| m.max(values[i]));
|
||||
assert_eq!(v.to_bits(), want.to_bits(), "len {len} radius {radius} x {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn trap_preserves_paper_alpha_and_spare_channels_exactly() {
|
||||
for (w, h, ch) in [(1, 31, 5), (47, 1, 4), (37, 29, 4), (37, 29, 5), (23, 19, 8)] {
|
||||
for radius in [1, 3, 10, 128] {
|
||||
let src: Vec<f32> = (0..w * h * ch).map(|i| if (i / ch) % 5 == 0 { 0.0 } else { ((i * 71 + i / ch * 103) % 65536) as f32 / 65535.0 }).collect();
|
||||
let mut want = src.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let index = (y * w + x) * ch;
|
||||
if !(0..4).any(|c| src[index + c] > 1e-4) {
|
||||
continue;
|
||||
}
|
||||
for c in 0..4 {
|
||||
let mut m = 0.0f32;
|
||||
for yy in y.saturating_sub(radius)..=y.saturating_add(radius).min(h - 1) {
|
||||
for xx in x.saturating_sub(radius)..=x.saturating_add(radius).min(w - 1) {
|
||||
m = m.max(src[(yy * w + xx) * ch + c]);
|
||||
}
|
||||
}
|
||||
want[index + c] = src[index + c].max(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut got = src;
|
||||
trap(&mut got, w, h, ch, radius);
|
||||
assert_eq!(got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), want.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn malformed_buffer_is_left_alone() {
|
||||
let mut pixels = vec![0.5; 20];
|
||||
let original = pixels.clone();
|
||||
trap(&mut pixels, usize::MAX, 2, 5, 10);
|
||||
assert_eq!(pixels, original);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,73 @@ impl VpPlane {
|
||||
});
|
||||
Lifted { focal: f, center: c, pts }
|
||||
}
|
||||
|
||||
/// Turn of each corner (`sin` of the angle between consecutive edges, positive when the
|
||||
/// corners run clockwise on screen); `None` when an edge is shorter than a pixel or a corner
|
||||
/// is not finite.
|
||||
fn turns(&self) -> Option<[f64; 4]> {
|
||||
let q = &self.corners;
|
||||
if q.iter().flatten().any(|v| !v.is_finite() || v.abs() > 1e9) {
|
||||
return None;
|
||||
}
|
||||
let e = |i: usize| [q[(i + 1) % 4][0] - q[i][0], q[(i + 1) % 4][1] - q[i][1]];
|
||||
let mut out = [0.0; 4];
|
||||
for (i, t) in out.iter_mut().enumerate() {
|
||||
let (a, b) = (e(i), e((i + 1) % 4));
|
||||
let (la, lb) = (a[0].hypot(a[1]), b[0].hypot(b[1]));
|
||||
if la < 1.0 || lb < 1.0 {
|
||||
return None;
|
||||
}
|
||||
*t = (a[0] * b[1] - a[1] * b[0]) / (la * lb);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// How usable the plane is, the way Photoshop colours its grid: [`PlaneCheck::Invalid`] for
|
||||
/// a quad that isn't convex (self-intersecting, a corner pushed inside, a near-straight or
|
||||
/// near-zero corner), [`PlaneCheck::Questionable`] for a convex quad whose perspective can't
|
||||
/// come from a camera centred at `center` (or with a corner sharper than 10°), else
|
||||
/// [`PlaneCheck::Valid`].
|
||||
pub fn check(&self, center: [f64; 2]) -> PlaneCheck {
|
||||
let Some(t) = self.turns() else { return PlaneCheck::Invalid };
|
||||
let same_sign = t.iter().all(|v| *v > 0.0) || t.iter().all(|v| *v < 0.0);
|
||||
let min = t.iter().fold(f64::MAX, |m, v| m.min(v.abs()));
|
||||
if !same_sign || min < 1f64.to_radians().sin() || self.homography().is_none() {
|
||||
return PlaneCheck::Invalid;
|
||||
}
|
||||
if min < 10f64.to_radians().sin() {
|
||||
return PlaneCheck::Questionable;
|
||||
}
|
||||
// Both vanishing points finite but no real focal length fits them.
|
||||
let q = &self.corners;
|
||||
let (v1, v2) = (meet(q[0], q[1], q[3], q[2]), meet(q[0], q[3], q[1], q[2]));
|
||||
let finite = |v: V3| v[2].abs() > 1e-9 * (v[0].abs() + v[1].abs()).max(1.0);
|
||||
if finite(v1) && finite(v2) && self.focal(center).is_none() {
|
||||
return PlaneCheck::Questionable;
|
||||
}
|
||||
PlaneCheck::Valid
|
||||
}
|
||||
|
||||
/// The same quad with its corners clockwise on screen, starting at the top-left one
|
||||
/// (the order [`paste`] and [`Scene::tear_off`] expect), however they were clicked.
|
||||
pub fn normalized(&self) -> VpPlane {
|
||||
let mut q = self.corners;
|
||||
if self.turns().is_some_and(|t| t.iter().sum::<f64>() < 0.0) {
|
||||
q = [q[0], q[3], q[2], q[1]];
|
||||
}
|
||||
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1]))).unwrap_or(0);
|
||||
VpPlane { corners: [q[first], q[(first + 1) % 4], q[(first + 2) % 4], q[(first + 3) % 4]] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Plane validity ([`VpPlane::check`]): Photoshop draws a valid plane's grid blue, a doubtful
|
||||
/// one yellow and an invalid one red.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PlaneCheck {
|
||||
Valid,
|
||||
Questionable,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl Lifted {
|
||||
@@ -363,6 +430,25 @@ mod tests {
|
||||
assert!((lu / lv - 2.0).abs() < 0.01, "aspect {}", lu / lv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_check_and_corner_order() {
|
||||
let c = [400.0, 300.0];
|
||||
assert_eq!(floor_quad(500.0, c).check(c), PlaneCheck::Valid);
|
||||
let square = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
|
||||
assert_eq!(square.check(c), PlaneCheck::Valid, "fronto-parallel");
|
||||
let bowtie = VpPlane { corners: [[10.0, 10.0], [110.0, 110.0], [110.0, 10.0], [10.0, 110.0]] };
|
||||
assert_eq!(bowtie.check(c), PlaneCheck::Invalid);
|
||||
let dent = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [40.0, 40.0], [10.0, 110.0]] };
|
||||
assert_eq!(dent.check(c), PlaneCheck::Invalid, "concave");
|
||||
let sliver = VpPlane { corners: [[10.0, 10.0], [300.0, 10.0], [300.0, 40.0], [200.0, 40.0]] };
|
||||
assert_eq!(sliver.check(c), PlaneCheck::Questionable, "a corner sharper than 10°");
|
||||
let nan = VpPlane { corners: [[f64::NAN, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
|
||||
assert_eq!(nan.check(c), PlaneCheck::Invalid);
|
||||
// Clicked counter-clockwise from the bottom-right: clockwise from the top-left.
|
||||
let ccw = VpPlane { corners: [[110.0, 110.0], [110.0, 10.0], [10.0, 10.0], [10.0, 110.0]] };
|
||||
assert_eq!(ccw.normalized(), square);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tear_off_is_perpendicular_and_shares_the_edge() {
|
||||
let c = [400.0, 300.0];
|
||||
|
||||
@@ -394,17 +394,17 @@ mod tests {
|
||||
let out = warp_mesh_surface(&src, r, &|x, y| (x + 10.0, y - 2.0), Interp::Bilinear);
|
||||
assert_eq!(out.content_bounds(), r.translate(10, -2));
|
||||
assert_eq!(out.pixel(20, 10), src.pixel(10, 12));
|
||||
// Arc 50 %: the centre column keeps its pixels, the ends drop.
|
||||
// Arc 50 % (Photoshop): the bottom corners keep their pixels, the top rises and spreads.
|
||||
let b = [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)];
|
||||
let w = Warp::preset(WarpStyle::Arc, 50.0, b);
|
||||
let arc = warp_mesh_surface(&src, r, &|x, y| w.map(x, y), Interp::Bilinear);
|
||||
let cx = (r.x0 + r.x1) / 2;
|
||||
let (p, q) = (arc.pixel(cx, 10), src.pixel(cx, 10));
|
||||
assert!(p.iter().zip(&q).all(|(a, b)| (a - b).abs() < 0.05), "{p:?} vs {q:?}");
|
||||
let corner = w.map(f64::from(r.x0), f64::from(r.y1));
|
||||
assert!((corner.0 - f64::from(r.x0)).abs() < 1e-9 && (corner.1 - f64::from(r.y1)).abs() < 1e-9, "{corner:?}");
|
||||
let (ex, ey) = w.map(f64::from(r.x0) + 2.5, f64::from(r.y0) + 2.5);
|
||||
let e = arc.pixel(ex.floor() as i32, ey.floor() as i32);
|
||||
assert!(e[3] > 0.2, "a point near the top-left corner lands at its mapped position: {e:?}");
|
||||
assert!(arc.content_bounds().y1 > r.y1 + 2, "ends drop below the original box");
|
||||
let c = arc.content_bounds();
|
||||
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Client for the desktop app's JSON-lines control protocol
|
||||
//! (`docs/control-protocol.md`): one JSON request per line, replies matched
|
||||
//! by `id`. Keeps one connection and reconnects for the next call after a failure.
|
||||
//! by `id`. Keeps one connection, reconnecting after a failure — or before
|
||||
//! a call, once the connection has been idle long enough that the app
|
||||
//! (which closes idle control connections after
|
||||
//! [`IO_TIMEOUT`](crate::security::IO_TIMEOUT)) may already have closed it.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
@@ -12,16 +15,31 @@ use tokio::sync::Mutex;
|
||||
|
||||
use crate::AutomationError;
|
||||
use crate::budgets::MAX_RESPONSE_BYTES;
|
||||
use crate::security::{AUTH_METHOD, MAX_REQUEST_BYTES, validate_token};
|
||||
use crate::security::{AUTH_METHOD, IO_TIMEOUT, MAX_REQUEST_BYTES, validate_token};
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
/// Reconnect before sending when the cached connection has been idle this long: the app
|
||||
/// drops idle control connections after [`IO_TIMEOUT`], and reconnecting before the send
|
||||
/// keeps the request out of the "was it applied?" state. The margin covers a request
|
||||
/// still in flight when the idle clock runs out.
|
||||
const IDLE_RECONNECT: Duration = IO_TIMEOUT.saturating_sub(Duration::from_secs(5));
|
||||
|
||||
/// The default call timeout sits strictly below the app's 60s reply deadline, so this
|
||||
/// client's error — not the app's deadline reply, not the caller's own cutoff — is the
|
||||
/// one the caller sees.
|
||||
pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(55);
|
||||
|
||||
const NO_REPLY_CAUSES: &str =
|
||||
"the command may still be running, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)";
|
||||
|
||||
pub struct BridgeClient {
|
||||
addr: String,
|
||||
token: String,
|
||||
conn: Mutex<Option<Conn>>,
|
||||
conn: Mutex<Option<(Conn, Instant)>>,
|
||||
next_id: AtomicU64,
|
||||
timeout: Duration,
|
||||
idle_reconnect: Duration,
|
||||
}
|
||||
|
||||
impl BridgeClient {
|
||||
@@ -35,7 +53,14 @@ impl BridgeClient {
|
||||
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
|
||||
}
|
||||
validate_token(&token)?;
|
||||
Ok(BridgeClient { addr, token: token.to_ascii_lowercase(), conn: Mutex::new(None), next_id: AtomicU64::new(1), timeout: Duration::from_secs(60) })
|
||||
Ok(BridgeClient {
|
||||
addr,
|
||||
token: token.to_ascii_lowercase(),
|
||||
conn: Mutex::new(None),
|
||||
next_id: AtomicU64::new(1),
|
||||
timeout: DEFAULT_CALL_TIMEOUT,
|
||||
idle_reconnect: IDLE_RECONNECT,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, t: Duration) -> Self {
|
||||
@@ -43,19 +68,69 @@ impl BridgeClient {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shrink the idle-reconnect threshold; tests use it to run the reconnect path quickly.
|
||||
pub fn with_idle_reconnect(mut self, t: Duration) -> Self {
|
||||
self.idle_reconnect = t;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Call a control method; returns its `result` or the app's error.
|
||||
/// Call a control method; returns its `result` or the app's error. Never resends after
|
||||
/// a transport failure: the request may have been applied (#1007).
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
self.dispatch(method, ¶ms, false).await
|
||||
}
|
||||
|
||||
/// [`Self::call`] for a method the caller knows only reads state. A transport failure
|
||||
/// applied nothing, so one fresh attempt is made on a new connection.
|
||||
pub async fn call_read(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
self.dispatch(method, ¶ms, true).await
|
||||
}
|
||||
|
||||
async fn dispatch(&self, method: &str, params: &Value, read: bool) -> Result<Value, AutomationError> {
|
||||
match self.attempt(method, params).await {
|
||||
Ok(v) => Ok(v),
|
||||
// A dropped socket or an unsent request applied nothing: safe to retry once,
|
||||
// and only for a call the caller vouched is a read.
|
||||
Err(Failure::NotSent(_)) | Err(Failure::Transport(_)) if read => match self.attempt(method, params).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(retry) => Err(self.failure(method, retry, read)),
|
||||
},
|
||||
Err(f) => Err(self.failure(method, f, read)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for a failed attempt, worded by what the failure proves.
|
||||
fn failure(&self, method: &str, f: Failure, read: bool) -> AutomationError {
|
||||
match f {
|
||||
Failure::NotSent(e) => AutomationError::Bridge(format!("`{method}` failed: {e}; the request was not sent")),
|
||||
Failure::Transport(e) => AutomationError::Bridge(format!("`{method}` failed: {e}; operation may have completed; inspect state before retrying")),
|
||||
Failure::TimedOut(_) => {
|
||||
let what = if read { "a read may be retried safely" } else { "operation may have completed; inspect state before retrying" };
|
||||
AutomationError::Bridge(format!("`{method}` timed out after {:?}; {NO_REPLY_CAUSES}; {what}", self.timeout))
|
||||
}
|
||||
Failure::Definite(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// One exchange over one connection, reconnecting first when the cached one may be dead.
|
||||
async fn attempt(&self, method: &str, params: &Value) -> Result<Value, Failure> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
if let Some((_, last_used)) = guard.as_ref()
|
||||
&& last_used.elapsed() >= self.idle_reconnect
|
||||
{
|
||||
// The request below goes to a fresh socket, not one the app may have dropped.
|
||||
*guard = None;
|
||||
}
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|_| Failure::NotSent(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| {
|
||||
AutomationError::Bridge(format!(
|
||||
Failure::NotSent(format!(
|
||||
"cannot connect to {} ({e}); start the app with `photocraft --control <port>` and matching control credentials",
|
||||
self.addr
|
||||
))
|
||||
@@ -65,39 +140,65 @@ impl BridgeClient {
|
||||
let auth_id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let auth = tokio::time::timeout(Duration::from_secs(5), exchange(&mut conn, auth_id, AUTH_METHOD, &json!({"token": self.token})))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge("control authentication timed out".into()))??;
|
||||
auth?;
|
||||
*guard = Some(conn);
|
||||
.map_err(|_| Failure::NotSent("control authentication timed out".into()))?
|
||||
.map_err(|e| Failure::NotSent(e.to_string()))?;
|
||||
if let Err(e) = auth {
|
||||
return Err(Failure::Definite(e));
|
||||
}
|
||||
*guard = Some((conn, Instant::now()));
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
let Some((conn, _)) = guard.as_mut() else {
|
||||
return Err(Failure::NotSent(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
match tokio::time::timeout(self.timeout, exchange(conn, id, method, ¶ms)).await {
|
||||
let outcome = tokio::time::timeout(self.timeout, exchange(conn, id, method, params)).await;
|
||||
match outcome {
|
||||
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
|
||||
// An oversized frame leaves unread bytes. Drop this connection and
|
||||
// report the budget failure without retrying a possibly completed edit.
|
||||
*guard = None;
|
||||
Err(error)
|
||||
Err(Failure::Definite(error))
|
||||
}
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Ok(Ok(v))) => {
|
||||
if let Some((_, last_used)) = guard.as_mut() {
|
||||
*last_used = Instant::now();
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Ok(Ok(Err(e))) => {
|
||||
// The app answered: the operation did not apply — except the app's own
|
||||
// deadline reply, which fires while the command may still be running.
|
||||
if let Some((_, last_used)) = guard.as_mut() {
|
||||
*last_used = Instant::now();
|
||||
}
|
||||
Err(Failure::Definite(e))
|
||||
}
|
||||
// A failed write or lost reply does not prove the edit was not applied.
|
||||
// Reconnect on the next call; only a vouched read may resend (#1007).
|
||||
Ok(Err(e)) => {
|
||||
// A failed write or lost reply does not prove the edit was not applied.
|
||||
// Reconnect on the next call, but never resend this operation (#1007).
|
||||
*guard = None;
|
||||
Err(AutomationError::Bridge(format!("`{method}` failed: {e}; operation may have completed; inspect state before retrying")))
|
||||
Err(Failure::Transport(e.to_string()))
|
||||
}
|
||||
Err(_) => {
|
||||
*guard = None;
|
||||
Err(AutomationError::Bridge(format!(
|
||||
"`{method}` timed out after {:?}; operation may have completed; inspect state before retrying",
|
||||
self.timeout
|
||||
)))
|
||||
Err(Failure::TimedOut(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a single attempt failed, deciding the retry and the wording above.
|
||||
enum Failure {
|
||||
/// The connection could not be established or authenticated: nothing was sent.
|
||||
NotSent(String),
|
||||
/// The request was sent and the reply was lost: the outcome is uncertain.
|
||||
Transport(String),
|
||||
/// The call's own timeout elapsed. Retrying would only double the wait.
|
||||
TimedOut(()),
|
||||
/// The app answered (ok or error) or the request was refused before it was sent.
|
||||
Definite(AutomationError),
|
||||
}
|
||||
|
||||
/// Outer `Err` = transport failure with an uncertain outcome; inner = app-level result.
|
||||
async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Result<Result<Value, AutomationError>, AutomationError> {
|
||||
let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
@@ -133,3 +234,20 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_call_timeout_stays_below_the_app_reply_deadline() {
|
||||
// At or above the app's 60s deadline, callers would see their own cutoff first.
|
||||
assert!(DEFAULT_CALL_TIMEOUT < Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_reconnect_stays_below_the_app_idle_cutoff() {
|
||||
// Reconnecting later than IO_TIMEOUT would send into a socket the app dropped.
|
||||
assert!(IDLE_RECONNECT < IO_TIMEOUT);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,17 @@ pub fn previews(doc: &Document) -> SaveOptions {
|
||||
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) }
|
||||
}
|
||||
|
||||
/// Encode the name the saved native bundle will report on reopening. Save As updates
|
||||
/// the session identity only *after* a successful write; without a separate snapshot
|
||||
/// the manifest serializes the previous name instead.
|
||||
fn named_for_output(doc: &Document, path: &str) -> Document {
|
||||
let mut named = doc.clone();
|
||||
if let Some(name) = path.rsplit(['/', '\\']).next().filter(|name| !name.is_empty()) {
|
||||
named.name = name.to_owned();
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// Save or export `doc` to `path`, choosing the format from the extension
|
||||
/// (or `format_override`, an extension such as `"png"`). `writer` makes
|
||||
/// repeated `.pcraft` saves incremental. Returns warnings about lost data.
|
||||
@@ -56,9 +67,10 @@ pub fn save(
|
||||
.or_else(|| path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()))
|
||||
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{}`; pass a format", path.display())))?;
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
let named = named_for_output(doc, &path.to_string_lossy());
|
||||
let mut local = PcraftWriter::new();
|
||||
let w = writer.unwrap_or(&mut local);
|
||||
w.save_path(doc, path, &previews(doc))?;
|
||||
w.save_path(&named, path, &previews(&named))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let r = photocraft_io::export(doc, &ext, opts)?;
|
||||
@@ -75,7 +87,8 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt
|
||||
.or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase()))
|
||||
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
return Ok((photocraft_format::save_to_bytes(doc, &previews(doc))?, Vec::new()));
|
||||
let named = named_for_output(doc, name);
|
||||
return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new()));
|
||||
}
|
||||
let result = photocraft_io::export(doc, &ext, opts)?;
|
||||
Ok((result.bytes, result.warnings))
|
||||
|
||||
@@ -9,7 +9,7 @@ use photocraft_format::PcraftWriter;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::workspace::{authorize_engine_command, authorize_engine_step};
|
||||
use crate::workspace::{ExportSequence, authorize_engine_command, authorize_engine_step};
|
||||
use crate::{AuthorizedWorkspace, AutomationError, files};
|
||||
|
||||
fn untrusted(filesystem: Filesystem) -> Headless {
|
||||
@@ -106,6 +106,113 @@ impl Headless {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Place a file into a document as a Smart Object layer: the capability-scoped counterpart
|
||||
/// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in
|
||||
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
|
||||
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
|
||||
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
return self.command_run("file.placeEmbedded", Value::Object(opts));
|
||||
}
|
||||
let bytes = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace.read(path)?,
|
||||
_ => {
|
||||
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
|
||||
}
|
||||
};
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
|
||||
self.sync_jobs();
|
||||
// The bytes are already in memory and the path resolved beneath the root capability, so
|
||||
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
|
||||
Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?)
|
||||
}
|
||||
|
||||
/// Export each exported layer of a document as its own file beneath the write root: the
|
||||
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
|
||||
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
|
||||
/// both paths write the same files. The write capability refuses to overwrite a file that
|
||||
/// already exists, where the ambient command would, and it creates the directory before the
|
||||
/// export runs, so a refused export can leave an empty directory behind — never a file.
|
||||
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("dir".into(), json!(dir));
|
||||
return self.command_run("file.export.layersToFiles", Value::Object(opts));
|
||||
}
|
||||
self.sync_jobs();
|
||||
let workspace = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace,
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
}
|
||||
|
||||
/// Export each data set of a document as its own flattened file beneath the write root: the
|
||||
/// capability-scoped `file.export.dataSetsAsFiles` (#3216). As in the command, the whole
|
||||
/// batch is planned first and colliding names are refused before any file is written; the
|
||||
/// directory itself is created before that, so a refused batch can leave it empty behind.
|
||||
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("dir".into(), json!(dir));
|
||||
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
|
||||
}
|
||||
self.sync_jobs();
|
||||
let workspace = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace,
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
}
|
||||
|
||||
/// Import a data set CSV from the read root: the capability-scoped
|
||||
/// `file.import.variableDataSets`, which takes an ambient `path` and is therefore refused
|
||||
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
|
||||
/// handed to the command's own parser over the text it already has.
|
||||
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
|
||||
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
|
||||
}
|
||||
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
return self.command_run("file.import.variableDataSets", Value::Object(opts));
|
||||
}
|
||||
let text = match &self.filesystem {
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let bytes = workspace.read(path)?;
|
||||
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
|
||||
}
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
};
|
||||
self.sync_jobs();
|
||||
Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?)
|
||||
}
|
||||
|
||||
/// Save (`.pcraft` or any export format by extension). With no path,
|
||||
/// saves to the document's own path.
|
||||
pub fn save(&mut self, index: Option<usize>, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result<Value, AutomationError> {
|
||||
@@ -282,3 +389,20 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common tail of the two capability-scoped exports: the sequence keeps what it wrote, or drops
|
||||
/// every file it created, so a half-finished export never looks like a finished one.
|
||||
fn exported(dir: &str, written: photocraft_engine::Result<Vec<String>>, outputs: &mut ExportSequence) -> Result<Value, AutomationError> {
|
||||
match written {
|
||||
Ok(names) => {
|
||||
outputs.commit();
|
||||
let files: Vec<String> = names.iter().map(|name| format!("{dir}/{name}")).collect();
|
||||
let count = files.len();
|
||||
Ok(json!({"files": files, "count": count}))
|
||||
}
|
||||
Err(error) => {
|
||||
outputs.cleanup()?;
|
||||
Err(error.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,12 @@
|
||||
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
|
||||
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
|
||||
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
|
||||
//! `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
|
||||
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
|
||||
//! the counterpart of the refused ambient `file.placeEmbedded`),
|
||||
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
|
||||
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
|
||||
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
|
||||
//! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
|
||||
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
|
||||
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
|
||||
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
|
||||
@@ -37,8 +42,12 @@ pub const METHODS: &[&str] = &[
|
||||
"engine.commands",
|
||||
"session.list",
|
||||
"doc.open",
|
||||
"doc.place",
|
||||
"doc.new",
|
||||
"doc.save",
|
||||
"doc.exportLayers",
|
||||
"doc.exportDataSets",
|
||||
"doc.importDataSets",
|
||||
"doc.inspect",
|
||||
"doc.render",
|
||||
"doc.select",
|
||||
@@ -95,10 +104,18 @@ impl Headless {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?;
|
||||
self.open(&PathBuf::from(path))
|
||||
}
|
||||
"doc.place" => {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?;
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("path");
|
||||
opts.remove("index");
|
||||
self.place(index_of(&p), path, &Value::Object(opts))
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), ..Default::default() };
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_quality = q.clamp(1, 100) as u8;
|
||||
@@ -107,6 +124,21 @@ impl Headless {
|
||||
let path = str_of(&p, "path").map(PathBuf::from);
|
||||
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
|
||||
}
|
||||
"doc.exportLayers" | "doc.exportDataSets" => {
|
||||
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("dir");
|
||||
opts.remove("index");
|
||||
let opts = Value::Object(opts);
|
||||
if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) }
|
||||
}
|
||||
"doc.importDataSets" => {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("path");
|
||||
opts.remove("index");
|
||||
self.import_data_sets(index_of(&p), path, &Value::Object(opts))
|
||||
}
|
||||
"doc.inspect" => self.inspect(index_of(&p)),
|
||||
"doc.render" => {
|
||||
let max = match p.get("maxSide") {
|
||||
@@ -571,6 +603,120 @@ mod tests {
|
||||
std::fs::remove_dir(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_place_reads_through_the_read_root_and_never_outside_it() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-place-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
let outside = base.join("outside");
|
||||
for dir in [&root, &outside] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 8 * 6 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
std::fs::write(root.join("logo.png"), &png).unwrap();
|
||||
std::fs::write(outside.join("logo.png"), &png).unwrap();
|
||||
let absolute = outside.join("logo.png").to_string_lossy().into_owned();
|
||||
|
||||
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
|
||||
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
let placed = h.handle("doc.place", json!({"path": "logo.png", "scale": 50})).unwrap();
|
||||
assert!(placed["layer"].as_u64().is_some(), "{placed}");
|
||||
assert_eq!(placed["scale"].as_f64(), Some(50.0), "the options reach the command: {placed}");
|
||||
let inspected = h.handle("doc.inspect", json!({})).unwrap();
|
||||
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "the placed layer is in the document: {inspected}");
|
||||
// A path outside the read root is refused the way the ambient command is (#2810).
|
||||
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside the automation root")] {
|
||||
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(refused.contains(reason), "{refused}");
|
||||
let inspected = h.handle("doc.inspect", json!({})).unwrap();
|
||||
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "nothing was placed: {refused}");
|
||||
}
|
||||
// Trusted local keeps the ambient command: that caller may read the path itself.
|
||||
let mut t = Headless::trusted_local();
|
||||
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
let trusted = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap();
|
||||
assert!(trusted["layer"].as_u64().is_some(), "{trusted}");
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(t);
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_export_layers_writes_only_beneath_the_write_root() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
|
||||
h.handle("doc.new", json!({"width": 12, "height": 8, "background": "white", "name": "card"})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Badge"}})).unwrap();
|
||||
|
||||
let out = root.join("out");
|
||||
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": "png", "prefix": "card"})).unwrap();
|
||||
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names.len(), 2, "{exported}");
|
||||
for name in names {
|
||||
assert!(name.starts_with("card_") && name.ends_with(".png"), "{name}");
|
||||
let bytes = std::fs::read(out.join(&name)).unwrap();
|
||||
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().dimensions(), (12, 8), "{name}");
|
||||
}
|
||||
// Outside the write root nothing is created, not even the directory.
|
||||
let escaped = base.join("escaped");
|
||||
let refused = h.handle("doc.exportLayers", json!({"dir": escaped.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(refused.contains("must be inside the automation root"), "{refused}");
|
||||
assert!(!escaped.exists());
|
||||
// The ambient command keeps its refusal for the very same directory.
|
||||
let ambient =
|
||||
h.handle("engine.execute", json!({"command": "file.export.layersToFiles", "params": {"dir": out.to_string_lossy()}})).unwrap_err().to_string();
|
||||
assert!(ambient.contains("ambient filesystem paths"), "{ambient}");
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_export_data_sets_refuse_collisions_and_import_reads_the_root() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-data-sets-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
|
||||
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
|
||||
let inspected = h.handle("doc.inspect", json!({})).unwrap();
|
||||
let layer = inspected["layers"][0]["id"].as_u64().expect("the layer id");
|
||||
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
|
||||
.unwrap();
|
||||
h.handle(
|
||||
"engine.execute",
|
||||
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
|
||||
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
|
||||
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sets = root.join("sets");
|
||||
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap();
|
||||
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
|
||||
assert_eq!(std::fs::read_dir(&sets).unwrap().count(), 2, "{exported}");
|
||||
// Colliding names are refused as in the command, before any file is written.
|
||||
let clash = root.join("clash");
|
||||
let refused = h.handle("doc.exportDataSets", json!({"dir": clash.to_string_lossy(), "naming": "{document}"})).unwrap_err().to_string();
|
||||
assert!(refused.contains("{index}"), "{refused}");
|
||||
assert_eq!(std::fs::read_dir(&clash).unwrap().count(), 0, "no file of the refused batch");
|
||||
// A CSV from the read root, and one outside it.
|
||||
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
|
||||
let imported = h.handle("doc.importDataSets", json!({"path": "sets.csv"})).unwrap();
|
||||
assert_eq!(imported["imported"].as_u64(), Some(2), "{imported}");
|
||||
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
|
||||
let outside = h.handle("doc.importDataSets", json!({"path": base.join("outside.csv").to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(outside.contains("must be inside the automation root"), "{outside}");
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_local_render_keeps_full_size_behavior() {
|
||||
let mut h = Headless::trusted_local();
|
||||
|
||||
@@ -52,6 +52,79 @@ pub struct OpenParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlaceParams {
|
||||
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
|
||||
pub path: String,
|
||||
/// Document index from `session_list` (default: the active document).
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// Scale as a percentage; defaults to fitting a file larger than the canvas.
|
||||
#[serde(default)]
|
||||
pub scale: Option<f64>,
|
||||
/// Place at a centred size instead of a scaled one.
|
||||
#[serde(default)]
|
||||
pub fit: Option<bool>,
|
||||
/// Centre of the placed layer, in document pixels.
|
||||
#[serde(default)]
|
||||
pub center: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportLayersParams {
|
||||
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
|
||||
pub dir: String,
|
||||
/// Document index from `session_list` (default: the active document).
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// Export format (default png).
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// Filename prefix (default: the document name).
|
||||
#[serde(default)]
|
||||
pub prefix: Option<String>,
|
||||
/// Export only the layers that are visible (default true).
|
||||
#[serde(default, rename = "visible_only")]
|
||||
pub visible_only: Option<bool>,
|
||||
/// Encoder quality, 0..12, for the formats that take one.
|
||||
#[serde(default)]
|
||||
pub quality: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportDataSetsParams {
|
||||
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
|
||||
pub dir: String,
|
||||
/// Document index from `session_list` (default: the active document).
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// Export format (default png).
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// Only these data sets, by name; default every one of them.
|
||||
#[serde(default, rename = "data_sets")]
|
||||
pub data_sets: Option<Vec<String>>,
|
||||
/// Filename template with `{name}`, `{index}` and `{document}` (default `{name}`).
|
||||
#[serde(default)]
|
||||
pub naming: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportDataSetsParams {
|
||||
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
|
||||
pub path: String,
|
||||
/// Document index from `session_list` (default: the active document).
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// Field delimiter (default `,`).
|
||||
#[serde(default)]
|
||||
pub delimiter: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NewParams {
|
||||
@@ -90,6 +163,10 @@ pub struct SaveParams {
|
||||
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
|
||||
#[serde(default, rename = "tiffLayers")]
|
||||
pub tiff_layers: bool,
|
||||
/// TGA: 24 or 32 bits per pixel. 32 writes the alpha channel (or else the transparency) as the
|
||||
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
|
||||
#[serde(default, rename = "tgaBits")]
|
||||
pub tga_bits: Option<u8>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -242,7 +319,8 @@ pub struct UiSetParams {
|
||||
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
|
||||
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error.
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -432,7 +510,7 @@ impl PhotocraftMcp {
|
||||
if max_side.is_some_and(|side| side > MAX_PREVIEW_SIDE) {
|
||||
return Ok(fail(format!("automation preview side exceeds {MAX_PREVIEW_SIDE} pixels")));
|
||||
}
|
||||
let response = match b.call("ui.screenshot", json!({})).await {
|
||||
let response = match b.call_read("ui.screenshot", json!({})).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => return Ok(fail(error)),
|
||||
};
|
||||
@@ -500,7 +578,7 @@ impl PhotocraftMcp {
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call("ui.inspect", json!({})).await)
|
||||
to_result(b.call_read("ui.inspect", json!({})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")]
|
||||
@@ -515,6 +593,107 @@ impl PhotocraftMcp {
|
||||
to_result(b.call("app.open", json!({"path": p.path})).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own."
|
||||
)]
|
||||
async fn doc_place(&self, Parameters(p): Parameters<PlaceParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.scale {
|
||||
opts.insert("scale".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.fit {
|
||||
opts.insert("fit".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.center {
|
||||
opts.insert("center".into(), json!(v));
|
||||
}
|
||||
let opts = Value::Object(opts);
|
||||
// Bridge mode runs the app on this machine, which reads the path itself like any command.
|
||||
let mut bridge_params = opts.clone();
|
||||
if let Some(o) = bridge_params.as_object_mut() {
|
||||
o.insert("path".into(), Value::String(p.path.clone()));
|
||||
}
|
||||
let (path, index) = (p.path, p.index);
|
||||
if let Some(r) = self.headless_op(move |h| h.place(index, &path, &opts)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
self.run_command("file.placeEmbedded".into(), bridge_params, true).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
|
||||
)]
|
||||
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.format {
|
||||
opts.insert("format".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.prefix {
|
||||
opts.insert("prefix".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.visible_only {
|
||||
opts.insert("visibleOnly".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.quality {
|
||||
opts.insert("quality".into(), json!(v));
|
||||
}
|
||||
let opts = Value::Object(opts);
|
||||
// Bridge mode runs the app on this machine, which writes its own paths like any command.
|
||||
let mut bridge_params = opts.clone();
|
||||
if let Some(o) = bridge_params.as_object_mut() {
|
||||
o.insert("dir".into(), Value::String(p.dir.clone()));
|
||||
}
|
||||
let (dir, index) = (p.dir, p.index);
|
||||
if let Some(r) = self.headless_op(move |h| h.export_layers(index, &dir, &opts)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
self.run_command("file.export.layersToFiles".into(), bridge_params, true).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Export every data set of the document as its own flattened file beneath the write root (the capability-scoped Export Data Sets). The batch is planned first, so colliding filenames are refused before any file is written."
|
||||
)]
|
||||
async fn doc_export_data_sets(&self, Parameters(p): Parameters<ExportDataSetsParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.format {
|
||||
opts.insert("format".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.data_sets {
|
||||
opts.insert("dataSets".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.naming {
|
||||
opts.insert("naming".into(), json!(v));
|
||||
}
|
||||
let opts = Value::Object(opts);
|
||||
let mut bridge_params = opts.clone();
|
||||
if let Some(o) = bridge_params.as_object_mut() {
|
||||
o.insert("dir".into(), Value::String(p.dir.clone()));
|
||||
}
|
||||
let (dir, index) = (p.dir, p.index);
|
||||
if let Some(r) = self.headless_op(move |h| h.export_data_sets(index, &dir, &opts)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
self.run_command("file.export.dataSetsAsFiles".into(), bridge_params, true).await
|
||||
}
|
||||
|
||||
#[tool(description = "Import a data set CSV from the read root into the document (the capability-scoped Import Variable Data Sets).")]
|
||||
async fn doc_import_data_sets(&self, Parameters(p): Parameters<ImportDataSetsParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.delimiter {
|
||||
opts.insert("delimiter".into(), json!(v));
|
||||
}
|
||||
let opts = Value::Object(opts);
|
||||
let mut bridge_params = opts.clone();
|
||||
if let Some(o) = bridge_params.as_object_mut() {
|
||||
o.insert("path".into(), Value::String(p.path.clone()));
|
||||
}
|
||||
let (path, index) = (p.path, p.index);
|
||||
if let Some(r) = self.headless_op(move |h| h.import_data_sets(index, &path, &opts)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
self.run_command("file.import.variableDataSets".into(), bridge_params, true).await
|
||||
}
|
||||
|
||||
#[tool(description = "Create a new document (defaults: 1920x1080 RGB 8-bit white).")]
|
||||
async fn doc_new(&self, Parameters(p): Parameters<NewParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut params = serde_json::Map::new();
|
||||
@@ -571,7 +750,7 @@ impl PhotocraftMcp {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
let params = p.index.map(|index| json!({"document": index})).unwrap_or_else(|| json!({}));
|
||||
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": params})).await)
|
||||
to_result(b.call_read("engine.execute", json!({"command": "document.inspect", "params": params})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
|
||||
@@ -621,7 +800,7 @@ impl PhotocraftMcp {
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
b.call("engine.commands", json!({})).await
|
||||
b.call_read("engine.commands", json!({})).await
|
||||
};
|
||||
let all = match all {
|
||||
Ok(v) => v,
|
||||
@@ -653,17 +832,18 @@ impl PhotocraftMcp {
|
||||
#[tool(description = "List background jobs: running ones with progress (0-1), message and elapsed time, then the last few \
|
||||
that ended (state done|failed|cancelled with their result or error). Finished jobs are applied first.")]
|
||||
async fn jobs_list(&self) -> Result<CallToolResult, McpError> {
|
||||
self.jobs_call("jobs.list", json!({})).await
|
||||
self.jobs_call("jobs.list", json!({}), true).await
|
||||
}
|
||||
|
||||
#[tool(description = "Cancel a background job by id (or every running job when `job` is omitted). The document is left \
|
||||
unchanged.")]
|
||||
async fn jobs_cancel(&self, Parameters(p): Parameters<JobCancelParams>) -> Result<CallToolResult, McpError> {
|
||||
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j}))).await
|
||||
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j})), false).await
|
||||
}
|
||||
|
||||
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
|
||||
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")]
|
||||
#[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \
|
||||
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \
|
||||
Not an atomic transaction: earlier successful steps stay applied (no rollback).")]
|
||||
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
|
||||
if p.steps.len() > MAX_BATCH_STEPS {
|
||||
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
|
||||
@@ -712,7 +892,7 @@ impl PhotocraftMcp {
|
||||
)]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
|
||||
Some(b) => to_result(b.call_read("ui.inspect", json!({})).await),
|
||||
None => bridge_only("ui_inspect"),
|
||||
}
|
||||
}
|
||||
@@ -787,8 +967,8 @@ impl PhotocraftMcp {
|
||||
to_result(b.call("engine.execute", json!({"command": id, "params": params, "wait": wait})).await)
|
||||
}
|
||||
|
||||
/// `jobs.list` / `jobs.cancel` in either backend.
|
||||
async fn jobs_call(&self, method: &'static str, params: Value) -> Result<CallToolResult, McpError> {
|
||||
/// `jobs.list` / `jobs.cancel` in either backend; `read` picks the bridge's retry rule.
|
||||
async fn jobs_call(&self, method: &'static str, params: Value, read: bool) -> Result<CallToolResult, McpError> {
|
||||
let p2 = params.clone();
|
||||
if let Some(r) = self.headless_op(move |h| h.command_start(method, p2, true)).await {
|
||||
return to_result(r);
|
||||
@@ -796,15 +976,20 @@ impl PhotocraftMcp {
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call(method, params).await)
|
||||
if read { to_result(b.call_read(method, params).await) } else { to_result(b.call(method, params).await) }
|
||||
}
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
let tga_bits = match p.tga_bits.map(|b| (b, photocraft_io::TgaBits::from_bits(u64::from(b)))) {
|
||||
None => None,
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
|
||||
};
|
||||
if let Some(b) = self.bridge_client() {
|
||||
// The bridge forwards to the running app's `app.save`, which takes only an optional
|
||||
// path (without one it writes back to the document's own layered file, as headless
|
||||
// does), so the extra options are headless-only. Saying so beats saving with defaults
|
||||
// while the caller believes their quality or format was applied.
|
||||
// does) and the Targa bits, so the other options are headless-only. Saying so beats
|
||||
// saving with defaults while the caller believes their quality or format was applied.
|
||||
let unsupported: Vec<&str> = [
|
||||
p.format.is_some().then_some("format"),
|
||||
p.quality.is_some().then_some("quality"),
|
||||
@@ -817,15 +1002,18 @@ impl PhotocraftMcp {
|
||||
if !unsupported.is_empty() {
|
||||
return Ok(fail(format!("bridge mode saves with the app's current settings; `{}` need headless mode", unsupported.join("`, `"))));
|
||||
}
|
||||
let params = match p.path {
|
||||
Some(path) => json!({"path": path}),
|
||||
None => json!({}),
|
||||
};
|
||||
let mut params = json!({});
|
||||
if let Some(path) = p.path {
|
||||
params["path"] = json!(path);
|
||||
}
|
||||
if let Some(bits) = tga_bits {
|
||||
params["tgaBits"] = json!(bits.bits());
|
||||
}
|
||||
return to_result(b.call("app.save", params).await);
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
|
||||
@@ -16,7 +16,7 @@ fn annotated(mut tool: Tool) -> Tool {
|
||||
tool.name.as_ref(),
|
||||
"command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot"
|
||||
);
|
||||
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open");
|
||||
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place");
|
||||
tool.title = Some(title.clone());
|
||||
tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false)));
|
||||
Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false));
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::security::MAX_REQUEST_BYTES;
|
||||
|
||||
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
|
||||
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
|
||||
/// executable at EOF after an oversized suffix is rejected.
|
||||
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let available = source.fill_buf().await?;
|
||||
if available.is_empty() {
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
|
||||
}
|
||||
let newline = available.iter().position(|&byte| byte == b'\n');
|
||||
let length = newline.map_or(available.len(), |at| at + 1);
|
||||
if length > maximum.saturating_sub(bytes.len()) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("request exceeds {maximum} bytes")));
|
||||
}
|
||||
bytes.extend_from_slice(&available[..length]);
|
||||
source.consume(length);
|
||||
if newline.is_some() {
|
||||
bytes.pop();
|
||||
if bytes.last() == Some(&b'\r') {
|
||||
bytes.pop();
|
||||
}
|
||||
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PhotocraftMcp {
|
||||
/// Serve a JSON-lines stream. A malformed line receives -32700/id:null; the next line
|
||||
/// is still handled. A single writer serializes parse errors and SDK responses.
|
||||
@@ -14,8 +46,8 @@ impl PhotocraftMcp {
|
||||
let (service_out, from_service) = tokio::io::duplex(1 << 20);
|
||||
let (errors, mut error_rx) = tokio::sync::mpsc::channel::<String>(16);
|
||||
let reader = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let mut lines = BufReader::new(input);
|
||||
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -59,3 +91,56 @@ impl PhotocraftMcp {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_line_accepts_exact_limit_and_final_line() {
|
||||
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
|
||||
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
|
||||
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
|
||||
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
|
||||
|
||||
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
|
||||
assert_eq!(bounded_line(&mut oversized_eof, 5).await.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_valid_tool_call_never_reaches_dispatch() {
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "admission-test", "version": "1" }
|
||||
}
|
||||
});
|
||||
let mut input = format!("{initialize}\n{}\n", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).into_bytes();
|
||||
let call = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": { "name": "doc_new", "arguments": { "width": 8, "height": 8 } }
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes();
|
||||
assert!(call.len() < MAX_REQUEST_BYTES);
|
||||
input.extend_from_slice(&call);
|
||||
// The valid JSON prefix spans multiple BufReader fills; only its whitespace suffix
|
||||
// crosses the byte limit. No part of this line may be forwarded to rmcp.
|
||||
input.extend(vec![b' '; MAX_REQUEST_BYTES - call.len()]);
|
||||
input.push(b'\n');
|
||||
let server = PhotocraftMcp::headless();
|
||||
let observer = server.clone();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), server.serve_io(std::io::Cursor::new(input), tokio::io::sink()))
|
||||
.await
|
||||
.expect("oversized MCP input should end the session");
|
||||
assert!(result.unwrap_err().to_string().contains("request exceeds 1048576 bytes"));
|
||||
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
|
||||
assert_eq!(documents, 0, "the oversized tool call must not run");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Capability-based filesystem policy for untrusted automation paths.
|
||||
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -51,8 +51,22 @@ impl AuthorizedWorkspace {
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
// Bounded reads, and a clear error for a file larger than memory (#375).
|
||||
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
|
||||
read_to_observed_length(&mut file, metadata.len())
|
||||
}
|
||||
|
||||
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
|
||||
/// read root. `read` serves regular files only (the zip form), so a regular file at the
|
||||
/// path is reported as `Ok(None)` for the caller to fall back to.
|
||||
pub fn open_pcraft(&self, path: &str) -> Result<Option<photocraft_doc::Document>, AutomationError> {
|
||||
let relative = request_path(path, self.read.as_ref())?;
|
||||
let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?;
|
||||
let dir = match root.dir.open_dir(&relative) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotADirectory => return Ok(None),
|
||||
Err(e) => return Err(file_error("read", path, e)),
|
||||
};
|
||||
let source = DirHandle { dir };
|
||||
Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?))
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
@@ -93,6 +107,34 @@ impl AuthorizedWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result<Vec<u8>, AutomationError> {
|
||||
// Do not reserve from a potentially stale or attacker-controlled metadata length. Read in
|
||||
// chunks, and reject growth beyond the length observed on the opened file before appending.
|
||||
let mut bytes = Vec::new();
|
||||
let mut chunk = [0u8; 64 << 10];
|
||||
loop {
|
||||
let remaining = observed_length.saturating_sub(bytes.len() as u64);
|
||||
let ask = chunk.len().min(usize::try_from(remaining.saturating_add(1)).unwrap_or(chunk.len()));
|
||||
let n = match reader.read(&mut chunk[..ask]) {
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(AutomationError::Io(format!("automation read failed: {e}"))),
|
||||
};
|
||||
if n == 0 {
|
||||
return Ok(bytes);
|
||||
}
|
||||
if n as u64 > remaining {
|
||||
return Err(AutomationError::BadRequest(format!("automation file grew beyond its observed length of {observed_length} bytes")));
|
||||
}
|
||||
let needed = bytes.len().checked_add(n).ok_or_else(|| AutomationError::BadRequest("automation file is too large to read".into()))?;
|
||||
if needed > bytes.capacity() {
|
||||
let target = bytes.capacity().saturating_add(bytes.capacity() / 2).max(needed).min(usize::try_from(observed_length).unwrap_or(usize::MAX));
|
||||
bytes.try_reserve_exact(target - bytes.len()).map_err(|e| AutomationError::Io(format!("automation read allocation failed: {e}")))?;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapability>, AutomationError> {
|
||||
let Some(path) = path else { return Ok(None) };
|
||||
if path.as_os_str().is_empty() {
|
||||
@@ -173,6 +215,24 @@ fn path_error(path: &str, reason: &str) -> AutomationError {
|
||||
AutomationError::BadRequest(format!("automation path rejected: {reason}: `{path}`"))
|
||||
}
|
||||
|
||||
/// A `.pcraft` directory behind the read-root capability: bounded reads, no ambient paths.
|
||||
struct DirHandle {
|
||||
dir: Dir,
|
||||
}
|
||||
|
||||
impl photocraft_format::ByteSource for DirHandle {
|
||||
fn get(&self, path: &str, max: usize) -> photocraft_format::Result<Vec<u8>> {
|
||||
let missing = || photocraft_format::FormatError::Corrupt(format!("missing `{path}`"));
|
||||
let mut file = self.dir.open(path).map_err(|_| missing())?;
|
||||
let metadata = file.metadata().map_err(|_| missing())?;
|
||||
if !metadata.is_file() {
|
||||
// A FIFO or device would block the open and stream without end.
|
||||
return Err(photocraft_format::FormatError::Corrupt(format!("`{path}` is not a regular file")));
|
||||
}
|
||||
photocraft_format::read_entry(&mut file, metadata.len(), max, path)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_error(operation: &str, path: &str, error: std::io::Error) -> AutomationError {
|
||||
let kind = error.kind();
|
||||
AutomationError::Io(format!("automation {operation} `{path}` failed ({kind:?}): {error}"))
|
||||
@@ -274,7 +334,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
|
||||
}
|
||||
|
||||
fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
|
||||
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") {
|
||||
// Installed profiles scan OS and user directories even without a request path.
|
||||
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") {
|
||||
return true;
|
||||
}
|
||||
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
|
||||
@@ -431,6 +492,62 @@ mod tests {
|
||||
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_observed_length_does_not_trigger_a_fixed_limit_or_upfront_reservation() {
|
||||
let mut shortened = std::io::Cursor::new(b"psb");
|
||||
assert_eq!(read_to_observed_length(&mut shortened, (1 << 30) + 1).unwrap(), b"psb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_growth_is_rejected_at_the_observed_length() {
|
||||
let mut growing = std::io::repeat(b'x').take(65);
|
||||
let error = read_to_observed_length(&mut growing, 64).unwrap_err().to_string();
|
||||
assert!(error.contains("grew beyond its observed length of 64 bytes"), "{error}");
|
||||
let mut allowed = std::io::repeat(b'x').take(64);
|
||||
assert_eq!(read_to_observed_length(&mut allowed, 64).unwrap().len(), 64);
|
||||
let mut empty = std::io::Cursor::new(b"x");
|
||||
assert!(read_to_observed_length(&mut empty, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pcraft_loads_a_directory_below_the_read_root() {
|
||||
let (inside, _, workspace) = roots("pcraft-dir");
|
||||
let doc = photocraft_doc::Document::new("Native", photocraft_doc::Size::new(16, 12), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
photocraft_format::PcraftWriter::new().save_dir(&doc, &inside.join("native.pcraft"), &photocraft_format::SaveOptions::default()).unwrap();
|
||||
let loaded = workspace.open_pcraft("native.pcraft").unwrap().expect("the directory form loads");
|
||||
assert_eq!(loaded.name, doc.name);
|
||||
assert_eq!((loaded.size.width, loaded.size.height), (16, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pcraft_reports_zip_files_and_missing_paths_clearly() {
|
||||
let (inside, _, workspace) = roots("pcraft-errors");
|
||||
let doc = photocraft_doc::Document::new("Zipped", photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
let zip = inside.join("zipped.pcraft");
|
||||
photocraft_format::PcraftWriter::new().save_path(&doc, &zip, &photocraft_format::SaveOptions::default()).unwrap();
|
||||
assert!(zip.is_file());
|
||||
assert!(workspace.open_pcraft("zipped.pcraft").unwrap().is_none(), "the zip form falls back to the bytes path");
|
||||
let error = workspace.open_pcraft("missing.pcraft").unwrap_err().to_string();
|
||||
assert!(error.contains("failed"), "a missing path propagates its io error: {error}");
|
||||
assert!(AuthorizedWorkspace::default().open_pcraft("x.pcraft").is_err(), "no read authority fails closed");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn open_pcraft_rejects_non_regular_entries_and_escaping_symlinks() {
|
||||
let (inside, _, workspace) = roots("pcraft-nonregular");
|
||||
let bundle = inside.join("native.pcraft");
|
||||
std::fs::create_dir_all(bundle.join("manifest.json")).unwrap();
|
||||
let error = workspace.open_pcraft("native.pcraft").unwrap_err().to_string();
|
||||
assert!(error.contains("not a regular file"), "a directory entry is refused, not streamed: {error}");
|
||||
|
||||
let (inside2, _, workspace2) = roots("pcraft-symlink");
|
||||
let bundle2 = inside2.join("native.pcraft");
|
||||
std::fs::create_dir(&bundle2).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/zero", bundle2.join("manifest.json")).unwrap();
|
||||
assert!(workspace2.open_pcraft("native.pcraft").is_err(), "an escaping symlink entry is refused");
|
||||
}
|
||||
|
||||
fn temp_files(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect()
|
||||
}
|
||||
@@ -729,8 +846,11 @@ mod tests {
|
||||
| "path.moveHandle"
|
||||
| "path.bendSegment"
|
||||
| "path.convertPoint"
|
||||
| "path.addAnchor"
|
||||
| "path.deleteAnchor"
|
||||
| "path.clippingPath.set"
|
||||
| "path.rename"
|
||||
| "path.stroke"
|
||||
| "select.toWorkPath"
|
||||
| "layer.vectorMask.add"
|
||||
| "layer.vectorMask.edit"
|
||||
|
||||
@@ -330,6 +330,7 @@ async fn agent_play_refuses_a_recorded_file_open() {
|
||||
headless.session.actions.list.push(photocraft_engine::actions_cmds::Action {
|
||||
name: "Open".into(),
|
||||
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
|
||||
disabled: Default::default(),
|
||||
});
|
||||
let (c, server) = connect_headless(headless).await;
|
||||
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Bridge client behavior against a scripted fake control server: reconnect before
|
||||
//! idle sockets die, retry only caller-vouched reads, never resend edits (#1007),
|
||||
//! never retry timeouts.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use photocraft_automation::security::authentication_reply;
|
||||
use photocraft_automation::{AutomationError, BridgeClient};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const CONTROL_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// What the fake server does after serving one request on a connection.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum After {
|
||||
KeepOpen,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Serve connections until `max_connections` were seen. Each connection authenticates,
|
||||
/// then serves requests until it closes; every served request is recorded, and `accepted`
|
||||
/// counts connections as they arrive.
|
||||
async fn fake_server(
|
||||
max_connections: usize,
|
||||
after: After,
|
||||
seen: tokio::sync::mpsc::UnboundedSender<Vec<Value>>,
|
||||
accepted: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
tokio::spawn(async move {
|
||||
let mut connections = 0;
|
||||
while connections < max_connections {
|
||||
let Ok((socket, _)) = listener.accept().await else { break };
|
||||
connections += 1;
|
||||
accepted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let seen = seen.clone();
|
||||
let after = after;
|
||||
tokio::spawn(async move {
|
||||
let mut served = Vec::new();
|
||||
let mut socket = socket;
|
||||
authenticate(&mut socket).await;
|
||||
let (read, mut write) = socket.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let Ok(request) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
served.push(request.clone());
|
||||
let reply = json!({"id": request["id"], "ok": true, "result": {"served": served.len()}});
|
||||
if write.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if after == After::Close {
|
||||
break; // the reply is flushed, then both halves drop: a dead connection
|
||||
}
|
||||
}
|
||||
let _ = seen.send(served);
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Authenticate one connection: read the auth line, reply to it.
|
||||
async fn authenticate(socket: &mut TcpStream) {
|
||||
let (read, mut write) = socket.split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let (auth, ok) = authentication_reply(&line, CONTROL_TOKEN);
|
||||
assert!(ok, "each connection must authenticate");
|
||||
write.write_all(format!("{auth}\n").as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn read_retries_on_a_dropped_connection_and_edits_do_not() {
|
||||
// Connection 1 serves one read then dies; the next vouched read retries on
|
||||
// connection 2. The edit, sent after connection 2 also died, must fail with the
|
||||
// #1007 wording and never open connection 3.
|
||||
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let addr = fake_server(2, After::Close, seen_tx, accepted).await;
|
||||
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap();
|
||||
|
||||
let first = bridge.call_read("document.inspect", json!({})).await.expect("read served before the connection dies");
|
||||
assert_eq!(first["served"], 1);
|
||||
|
||||
let retried = bridge.call_read("document.inspect", json!({})).await.expect("a vouched read retried on a fresh connection");
|
||||
assert_eq!(retried["served"], 1, "connection 2 sees it as its own first request");
|
||||
|
||||
let edit = bridge.call("engine.execute", json!({"command": "layer.new.layer", "params": {}})).await;
|
||||
let error = edit.unwrap_err().to_string();
|
||||
assert!(error.contains("operation may have completed"), "{error}");
|
||||
assert!(error.contains("inspect state before retrying"), "{error}");
|
||||
assert!(!error.contains("retried"), "{error}");
|
||||
|
||||
let mut all = Vec::new();
|
||||
while let Ok(served) = seen_rx.try_recv() {
|
||||
all.extend(served);
|
||||
}
|
||||
assert_eq!(all.len(), 2, "one request per connection, no edit resend: {all:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_idle_connection_is_replaced_before_the_next_call() {
|
||||
// The production idle threshold sits under the app's 30s idle cutoff; the test
|
||||
// shrinks it so the reconnect path runs quickly. The next call after idling must
|
||||
// authenticate a new connection before sending.
|
||||
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let addr = fake_server(2, After::KeepOpen, seen_tx, std::sync::Arc::clone(&accepted)).await;
|
||||
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap().with_idle_reconnect(Duration::from_millis(100));
|
||||
|
||||
bridge.call_read("document.inspect", json!({})).await.expect("first call connects");
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
bridge.call_read("document.inspect", json!({})).await.expect("second call reconnects after idling");
|
||||
|
||||
assert_eq!(accepted.load(std::sync::atomic::Ordering::SeqCst), 2, "the idle connection was replaced, not reused");
|
||||
let first = seen_rx.try_recv().expect("the replaced connection closed, reporting what it served");
|
||||
assert_eq!(first.len(), 1, "each connection served only the request that followed its auth: {first:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_timed_out_call_is_not_retried() {
|
||||
// The server authenticates but never answers requests, keeping the connection open:
|
||||
// the call hits the client's own timeout, which must name the likely causes and must
|
||||
// not double the wait by retrying (the fake server counts every request it sees).
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let seen = std::sync::Arc::clone(&requests);
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
authenticate(&mut socket).await;
|
||||
let (read, _write) = socket.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if reader.read_line(&mut line).await.unwrap_or(0) == 0 {
|
||||
return;
|
||||
}
|
||||
if let Ok(request) = serde_json::from_str::<Value>(&line) {
|
||||
seen.lock().unwrap().push(request);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap().with_timeout(Duration::from_millis(300));
|
||||
let error = match bridge.call_read("document.inspect", json!({})).await {
|
||||
Err(e @ AutomationError::Bridge(_)) => e.to_string(),
|
||||
other => panic!("expected the bridge timeout, got {other:?}"),
|
||||
};
|
||||
assert!(error.contains("timed out after"), "{error}");
|
||||
assert!(error.contains("not drawing frames"), "{error}");
|
||||
assert!(error.contains("a read may be retried safely"), "{error}");
|
||||
assert!(!error.contains("may have completed"), "a read timeout never claims an applied edit: {error}");
|
||||
assert_eq!(requests.lock().unwrap().len(), 1, "a timed-out call is not resent");
|
||||
}
|
||||
@@ -79,6 +79,10 @@ async fn lists_expected_tools() {
|
||||
"session_list",
|
||||
"doc_open",
|
||||
"doc_new",
|
||||
"doc_place",
|
||||
"doc_export_layers",
|
||||
"doc_export_data_sets",
|
||||
"doc_import_data_sets",
|
||||
"doc_save",
|
||||
"doc_export",
|
||||
"doc_inspect",
|
||||
@@ -359,6 +363,49 @@ async fn bridge_doc_save_without_path_forwards_app_save_for_write_back() {
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// `tgaBits` reaches the app's `app.save` through the bridge; a value other than 24 or 32 is
|
||||
/// refused before anything is sent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_doc_save_forwards_targa_bits() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.tga", "tgaBits": 24})).await);
|
||||
assert_eq!(saved, json!({"saved": {"path": "out.tga", "tgaBits": 24}}));
|
||||
let r = call(&client, "doc_save", json!({"path": "out.tga", "tgaBits": 16})).await;
|
||||
assert_eq!(r.is_error, Some(true), "{}", text(&r));
|
||||
assert!(text(&r).contains("24 or 32"), "{}", text(&r));
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// Headless `doc_save`/`doc_export` write the TGA bits per pixel asked for (byte 16 of the
|
||||
/// header); unset, a document with an alpha channel is written as 32-bit.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_doc_save_writes_targa_bits() {
|
||||
let dir = tmp("tga-bits");
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
|
||||
for id in ["select.all", "select.saveSelection"] {
|
||||
let r = call(&client, "command_run", json!({"id": id, "params": {}})).await;
|
||||
assert_ne!(r.is_error, Some(true), "{}", text(&r));
|
||||
}
|
||||
for (tool, params, bpp) in [
|
||||
("doc_export", json!({"path": "auto.tga"}), 32),
|
||||
("doc_export", json!({"path": "flat.tga", "tgaBits": 24}), 24),
|
||||
("doc_save", json!({"path": "alpha.tga", "tgaBits": 32}), 32),
|
||||
] {
|
||||
let r = call(&client, tool, params.clone()).await;
|
||||
assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
|
||||
let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
|
||||
assert_eq!(bytes[16], bpp, "{params}");
|
||||
}
|
||||
let r = call(&client, "doc_export", json!({"path": "bad.tga", "tgaBits": 8})).await;
|
||||
assert_eq!(r.is_error, Some(true), "{}", text(&r));
|
||||
assert!(!dir.join("bad.tga").exists());
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
|
||||
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
|
||||
@@ -568,6 +615,107 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// #2810: a file from the read root places as a Smart Object layer; `file.placeEmbedded` itself
|
||||
/// stays refused, so `doc_place` is the only way in and the root is still the scope.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn doc_place_is_capability_scoped() {
|
||||
let base = tmp("place");
|
||||
let root = base.join("workspace");
|
||||
let outside = base.join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
std::fs::write(root.join("inside.png"), &bytes).unwrap();
|
||||
std::fs::write(outside.join("outside.png"), bytes).unwrap();
|
||||
let client = connect(headless_in(&root)).await;
|
||||
|
||||
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
|
||||
let placed = call(&client, "doc_place", json!({"path": "inside.png", "center": [10, 10]})).await;
|
||||
assert_ne!(placed.is_error, Some(true), "{}", text(&placed));
|
||||
let layers = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
|
||||
assert_eq!(layers, 2, "the placed layer is in the document");
|
||||
// An absolute path beneath the root is the same file (#2176), so it places too; outside it,
|
||||
// nothing.
|
||||
let inside = call(&client, "doc_place", json!({"path": root.join("inside.png").to_string_lossy()})).await;
|
||||
assert_ne!(inside.is_error, Some(true), "{}", text(&inside));
|
||||
let placed = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
|
||||
assert_eq!(placed, 3, "the absolute path beneath the root places as well");
|
||||
let refused = call(&client, "doc_place", json!({"path": outside.join("outside.png").to_string_lossy()})).await;
|
||||
assert_eq!(refused.is_error, Some(true));
|
||||
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
|
||||
let after = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
|
||||
assert_eq!(after, placed, "nothing outside the root was placed");
|
||||
// The ambient command keeps its refusal: the capability-scoped method is the only way in.
|
||||
let ambient = call(&client, "command_run", json!({"id": "file.placeEmbedded", "params": {"path": "inside.png"}})).await;
|
||||
assert_eq!(ambient.is_error, Some(true));
|
||||
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV,
|
||||
/// and the three ambient commands keep their refusal.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn doc_exports_and_data_set_import_are_capability_scoped() {
|
||||
let base = tmp("export");
|
||||
let root = base.join("workspace");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let client = connect(headless_in(&root)).await;
|
||||
|
||||
json_of(&call(&client, "doc_new", json!({"width": 10, "height": 6, "background": "white", "name": "card"})).await);
|
||||
let inspected = json_of(&call(&client, "doc_inspect", json!({})).await);
|
||||
let layer = inspected["layers"][0]["id"].clone();
|
||||
json_of(
|
||||
&call(&client, "command_run", json!({"id": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
|
||||
.await,
|
||||
);
|
||||
json_of(
|
||||
&call(
|
||||
&client,
|
||||
"command_run",
|
||||
json!({"id": "image.variables.dataSets", "params": {"dataSets": [
|
||||
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
|
||||
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
|
||||
]}}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
let layers = call(&client, "doc_export_layers", json!({"dir": "layers", "prefix": "card"})).await;
|
||||
assert_ne!(layers.is_error, Some(true), "{}", text(&layers));
|
||||
assert_eq!(json_of(&layers)["count"], 1, "{}", text(&layers));
|
||||
assert_eq!(std::fs::read_dir(root.join("layers")).unwrap().count(), 1);
|
||||
let sets = call(&client, "doc_export_data_sets", json!({"dir": "sets", "format": "png"})).await;
|
||||
assert_ne!(sets.is_error, Some(true), "{}", text(&sets));
|
||||
assert_eq!(json_of(&sets)["count"], 2, "{}", text(&sets));
|
||||
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2);
|
||||
// A colliding template is refused before any file of the batch is written.
|
||||
let clash = call(&client, "doc_export_data_sets", json!({"dir": "clash", "naming": "{document}"})).await;
|
||||
assert_eq!(clash.is_error, Some(true), "{}", text(&clash));
|
||||
assert_eq!(std::fs::read_dir(root.join("clash")).unwrap().count(), 0);
|
||||
// A CSV from the read root.
|
||||
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\n").unwrap();
|
||||
let imported = call(&client, "doc_import_data_sets", json!({"path": "sets.csv"})).await;
|
||||
assert_ne!(imported.is_error, Some(true), "{}", text(&imported));
|
||||
assert_eq!(json_of(&imported)["imported"], 1, "{}", text(&imported));
|
||||
|
||||
// Outside the roots nothing happens at all.
|
||||
let escaped = call(&client, "doc_export_layers", json!({"dir": base.join("escaped").to_string_lossy()})).await;
|
||||
assert_eq!(escaped.is_error, Some(true), "{}", text(&escaped));
|
||||
assert!(!base.join("escaped").exists());
|
||||
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
|
||||
let outside = call(&client, "doc_import_data_sets", json!({"path": base.join("outside.csv").to_string_lossy()})).await;
|
||||
assert_eq!(outside.is_error, Some(true), "{}", text(&outside));
|
||||
for command in ["file.export.layersToFiles", "file.export.dataSetsAsFiles", "file.import.variableDataSets"] {
|
||||
let refused = call(&client, "command_run", json!({"id": command, "params": {}})).await;
|
||||
assert_eq!(refused.is_error, Some(true), "{command}");
|
||||
assert!(text(&refused).contains("ambient filesystem paths"), "{}", text(&refused));
|
||||
}
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// The template workflow stays in memory, including saving nested contents back to the parent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
|
||||
@@ -892,6 +1040,18 @@ async fn bridge_reports_unreachable_app() {
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_description_exposes_non_atomic_semantics_when_tools_are_listed() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
let tool = tools.iter().find(|tool| tool.name == "command_batch").expect("command_batch tool");
|
||||
let description = tool.description.as_deref().expect("command_batch description");
|
||||
for contract in ["in order", "Not an atomic transaction", "first error", "stop_on_error is false", "no rollback"] {
|
||||
assert!(description.contains(contract), "command_batch description omits `{contract}`: {description}");
|
||||
}
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_runs_steps_in_order() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
@@ -1068,7 +1228,20 @@ async fn convention_resources_are_live_json() {
|
||||
assert!(read("photocraft://document").await["document"].is_null());
|
||||
json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await);
|
||||
assert_eq!(read("photocraft://document").await["width"], 8);
|
||||
assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await));
|
||||
// Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold
|
||||
// anything, and the other tests in this binary fill and purge those caches concurrently, so
|
||||
// its `enabled` flag can flip between the two reads. Every other field must match exactly.
|
||||
let stable = |mut v: Value| {
|
||||
for c in v.as_array_mut().into_iter().flatten() {
|
||||
if c["id"] == "edit.purge.all" {
|
||||
c.as_object_mut().map(|o| o.remove("enabled"));
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let commands = stable(read("photocraft://commands").await);
|
||||
assert!(commands.as_array().is_some_and(|a| a.len() > 100), "{commands}");
|
||||
assert_eq!(commands, stable(json_of(&call(&client, "command_list", json!({})).await)));
|
||||
assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err());
|
||||
let preview = call(&client, "render_preview", json!({"max_side":4})).await;
|
||||
assert!(preview.content.iter().any(|c| c.as_image().is_some()));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Save As must store the new filename in the native manifest, not just the in-session tab name.
|
||||
|
||||
use photocraft_automation::{Headless, files};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn native_save_as_reopens_with_the_written_filename() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pc-native-save-name-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let first = dir.join("FirstTest.pcraft");
|
||||
let second = dir.join("SecondTest.pcraft");
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Old Title"})).unwrap();
|
||||
headless.handle("doc.save", json!({"path": first.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(files::open(&first).unwrap().document.name, "FirstTest.pcraft");
|
||||
headless.handle("doc.save", json!({"path": second.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(headless.session.active().unwrap().doc.name, "SecondTest.pcraft");
|
||||
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
|
||||
// Save back in place and retain the correct title after another edit.
|
||||
headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap();
|
||||
headless.handle("doc.save", json!({})).unwrap();
|
||||
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_encoded_native_bundle_uses_requested_name_without_mutating_source() {
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Original"})).unwrap();
|
||||
let doc = &headless.session.active().unwrap().doc;
|
||||
let (bytes, warnings) = files::save_bytes(doc, "exports/Renamed.pcraft", None, &Default::default()).unwrap();
|
||||
assert!(warnings.is_empty());
|
||||
let reopened = files::open_bytes("Renamed.pcraft", &bytes).unwrap().document;
|
||||
assert_eq!(reopened.name, "Renamed.pcraft");
|
||||
assert_eq!(doc.name, "Original", "a copy should not retarget the open document");
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Codecs for flat raster image formats, written in pure Rust. The crate is standal
|
||||
workspace crate and defines its own `Image` type. It also builds for `wasm32-unknown-unknown` and
|
||||
does no I/O: decoders take `&[u8]` and encoders return `Vec<u8>`.
|
||||
|
||||
It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
|
||||
It follows the "Avoiding GIMP's hole" rules in `docs/architecture.md` §1.1:
|
||||
|
||||
* **Symmetric.** Every format we write, we can also read. The only exceptions are listed in
|
||||
`ASYMMETRIC_EXCEPTIONS`, each with a reason. The test suite checks this.
|
||||
@@ -44,6 +44,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
|
||||
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
|
||||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
|
||||
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
|
||||
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
|
||||
|
||||
@@ -62,6 +63,14 @@ the same.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
|
||||
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
|
||||
Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit
|
||||
stay exact as U8/U16 (a camera's `BITPIX = 16`, `BZERO = 32768`); the rest become F32, taken as
|
||||
normalised when at most 2 and scaled by 65535 (or the maximum) otherwise. Header cards are
|
||||
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
|
||||
`CodecError::Unsupported`; Bayer mosaics open undebayered.
|
||||
|
||||
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
@@ -92,7 +101,10 @@ the same.
|
||||
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
|
||||
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
|
||||
TIFF directories and transparency masks are not pages). Any TIFF page can still be opened
|
||||
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
|
||||
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes
|
||||
from `decode_gif_frames`: each frame composited onto the logical screen with the disposal
|
||||
methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and
|
||||
`MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`).
|
||||
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
|
||||
`caps.exif = false` for TIFF, and a warning is raised.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
|
||||
|
||||
@@ -62,3 +62,10 @@ path = "fuzz_targets/decode_heif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_gif"
|
||||
path = "fuzz_targets/decode_gif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,13 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, decode_gif_frames, DecodeOptions, Format, Limits};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Gif, data, &opts);
|
||||
let _ = decode_gif_frames(data, &opts);
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
//! FITS (Flexible Image Transport System, FITS Standard 4.0), implemented here: the image in the
|
||||
//! primary HDU, or the first `IMAGE` extension when the primary HDU holds no data.
|
||||
//!
|
||||
//! Reading:
|
||||
//!
|
||||
//! * `BITPIX` 8, 16, 32, 64, -32 and -64, with `BSCALE`/`BZERO` and integer `BLANK`.
|
||||
//! * `NAXIS3 = 3` opens as RGB (the planes are R, G, B); any other plane count opens the first
|
||||
//! plane as gray with [`DecodeWarning::MorePages`].
|
||||
//! * Rows are stored bottom-to-top, as the standard's convention has it (Siril, DS9, Astropy),
|
||||
//! unless `ROWORDER = 'TOP-DOWN'`.
|
||||
//! * Physical values that fit (unsigned 8-bit, or integers in 0..=65535 such as a camera's
|
||||
//! `BITPIX = 16`, `BZERO = 32768`) are kept exactly as U8/U16. Everything else becomes F32:
|
||||
//! values up to 2 are taken as already normalised (Siril, PixInsight), larger ones are scaled by
|
||||
//! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN
|
||||
//! and blank pixels read as 0.
|
||||
//! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and
|
||||
//! `HISTORY` by their text, `HIERARCH` keywords by their long name.
|
||||
//!
|
||||
//! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32
|
||||
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards.
|
||||
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
const F: Format = Format::Fits;
|
||||
const BLOCK: usize = 2880;
|
||||
const CARD: usize = 80;
|
||||
/// Most HDUs walked looking for an image; FITS files from cameras and stackers hold a handful.
|
||||
const MAX_HDUS: usize = 1000;
|
||||
|
||||
/// Keywords that describe the data layout. They are written from the image, never copied from
|
||||
/// (or kept as) text metadata: after decoding they no longer describe the pixels.
|
||||
const STRUCTURAL: &[&str] = &[
|
||||
"SIMPLE", "BITPIX", "NAXIS", "EXTEND", "BZERO", "BSCALE", "BLANK", "END", "XTENSION", "PCOUNT", "GCOUNT", "ROWORDER", "CHECKSUM", "DATASUM", "DATAMIN",
|
||||
"DATAMAX", "ZIMAGE",
|
||||
];
|
||||
|
||||
fn err(msg: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, msg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Value {
|
||||
/// A quoted string, unescaped, trailing blanks removed.
|
||||
Str(String),
|
||||
/// Anything else (number, logical, complex) as written.
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Card {
|
||||
key: String,
|
||||
/// `None` for commentary cards (`COMMENT`, `HISTORY`, blank keyword).
|
||||
value: Option<Value>,
|
||||
/// The text of a commentary card.
|
||||
text: String,
|
||||
/// A `HIERARCH` keyword (`key` holds its long name).
|
||||
hierarch: bool,
|
||||
}
|
||||
|
||||
fn parse_value(field: &str) -> Value {
|
||||
let t = field.trim_start();
|
||||
if let Some(rest) = t.strip_prefix('\'') {
|
||||
let mut s = String::new();
|
||||
let mut chars = rest.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\'' {
|
||||
if chars.peek() == Some(&'\'') {
|
||||
chars.next();
|
||||
s.push('\'');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
s.push(c);
|
||||
}
|
||||
}
|
||||
Value::Str(s.trim_end().to_string())
|
||||
} else {
|
||||
Value::Raw(t.split('/').next().unwrap_or("").trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_card(raw: &[u8]) -> Card {
|
||||
// Header bytes are ASCII 32–126 by the standard; anything else is replaced, never trusted.
|
||||
let s: String = raw.iter().map(|&b| if (32..=126).contains(&b) { b as char } else { '?' }).collect();
|
||||
let key_field = s.get(..8).unwrap_or(&s).trim_end().to_string();
|
||||
if key_field == "HIERARCH" {
|
||||
let rest = s.get(9..).unwrap_or("");
|
||||
if let Some((k, v)) = rest.split_once('=') {
|
||||
return Card { key: k.trim().to_string(), value: Some(parse_value(v)), text: String::new(), hierarch: true };
|
||||
}
|
||||
}
|
||||
if s.get(8..10) == Some("= ") {
|
||||
let v = parse_value(s.get(10..).unwrap_or(""));
|
||||
return Card { key: key_field, value: Some(v), text: String::new(), hierarch: false };
|
||||
}
|
||||
Card { key: key_field, value: None, text: s.get(8..).unwrap_or("").trim_end().to_string(), hierarch: false }
|
||||
}
|
||||
|
||||
struct Header {
|
||||
cards: Vec<Card>,
|
||||
/// Offset of the data unit.
|
||||
data_start: usize,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref())
|
||||
}
|
||||
|
||||
fn int(&self, key: &str) -> Option<i64> {
|
||||
match self.get(key)? {
|
||||
Value::Raw(r) => r.parse::<i64>().ok().or_else(|| self.float(key).filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64)),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn float(&self, key: &str) -> Option<f64> {
|
||||
match self.get(key)? {
|
||||
// Fortran-style `D` exponents are allowed in FITS.
|
||||
Value::Raw(r) => r.replace(['D', 'd'], "E").parse::<f64>().ok().filter(|f| f.is_finite()),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn logical(&self, key: &str) -> bool {
|
||||
matches!(self.get(key), Some(Value::Raw(r)) if r == "T")
|
||||
}
|
||||
|
||||
fn string(&self, key: &str) -> Option<&str> {
|
||||
match self.get(key)? {
|
||||
Value::Str(s) => Some(s.trim()),
|
||||
Value::Raw(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(b: &[u8], start: usize) -> Result<Header, CodecError> {
|
||||
let mut cards = Vec::new();
|
||||
let mut pos = start;
|
||||
loop {
|
||||
let raw = b.get(pos..pos.checked_add(CARD).ok_or_else(|| err("header too long"))?).ok_or_else(|| err("header ends before END"))?;
|
||||
pos += CARD;
|
||||
if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) {
|
||||
break;
|
||||
}
|
||||
cards.push(parse_card(raw));
|
||||
}
|
||||
let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?;
|
||||
Ok(Header { cards, data_start })
|
||||
}
|
||||
|
||||
/// The axis lengths (`NAXIS1`, `NAXIS2`, …).
|
||||
fn axes(h: &Header) -> Result<Vec<u64>, CodecError> {
|
||||
let n = h.int("NAXIS").ok_or_else(|| err("missing NAXIS"))?;
|
||||
if !(0..=999).contains(&n) {
|
||||
return Err(err(format!("invalid NAXIS {n}")));
|
||||
}
|
||||
(1..=n).map(|i| h.int(&format!("NAXIS{i}")).and_then(|v| u64::try_from(v).ok()).ok_or_else(|| err(format!("missing or negative NAXIS{i}")))).collect()
|
||||
}
|
||||
|
||||
/// Bytes in the data unit (before padding to a block).
|
||||
fn data_len(h: &Header, bitpix: i64) -> Result<u64, CodecError> {
|
||||
let ax = axes(h)?;
|
||||
if ax.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let pcount = u64::try_from(h.int("PCOUNT").unwrap_or(0)).map_err(|_| err("negative PCOUNT"))?;
|
||||
let gcount = u64::try_from(h.int("GCOUNT").unwrap_or(1)).map_err(|_| err("negative GCOUNT"))?;
|
||||
let elems = ax.iter().try_fold(1u64, |a, &n| a.checked_mul(n)).ok_or_else(|| err("data size overflows"))?;
|
||||
elems
|
||||
.checked_add(pcount)
|
||||
.and_then(|v| v.checked_mul(gcount))
|
||||
.and_then(|v| v.checked_mul(bitpix.unsigned_abs() / 8))
|
||||
.ok_or_else(|| err("data size overflows"))
|
||||
}
|
||||
|
||||
/// Finds the image HDU: the primary one when it has at least two axes, else the first `IMAGE`
|
||||
/// extension. Returns its header and the primary header (for its descriptive cards).
|
||||
fn find_image(b: &[u8]) -> Result<(Header, Option<Header>), CodecError> {
|
||||
let primary = parse_header(b, 0)?;
|
||||
if primary.get("SIMPLE").is_none() {
|
||||
return Err(err("missing SIMPLE"));
|
||||
}
|
||||
if axes(&primary)?.len() >= 2 {
|
||||
return Ok((primary, None));
|
||||
}
|
||||
let mut compressed = false;
|
||||
let mut cur_start = primary.data_start;
|
||||
let mut cur_len = data_len(&primary, primary.int("BITPIX").unwrap_or(8))?;
|
||||
for _ in 0..MAX_HDUS {
|
||||
let next = usize::try_from(cur_len.div_ceil(BLOCK as u64).saturating_mul(BLOCK as u64))
|
||||
.ok()
|
||||
.and_then(|l| cur_start.checked_add(l))
|
||||
.ok_or_else(|| err("data size overflows"))?;
|
||||
if next >= b.len() {
|
||||
break;
|
||||
}
|
||||
let h = parse_header(b, next)?;
|
||||
let kind = h.string("XTENSION").unwrap_or("").to_string();
|
||||
if kind == "IMAGE" && axes(&h)?.len() >= 2 {
|
||||
return Ok((h, Some(primary)));
|
||||
}
|
||||
if kind == "BINTABLE" && h.logical("ZIMAGE") {
|
||||
compressed = true;
|
||||
}
|
||||
cur_len = data_len(&h, h.int("BITPIX").unwrap_or(8))?;
|
||||
cur_start = h.data_start;
|
||||
}
|
||||
if compressed {
|
||||
return Err(CodecError::unsupported(F, "tile-compressed FITS (fpack) is not supported; decompress it with funpack first"));
|
||||
}
|
||||
Err(err("the file holds no image"))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let (h, primary) = find_image(bytes)?;
|
||||
let bitpix = h.int("BITPIX").ok_or_else(|| err("missing BITPIX"))?;
|
||||
if !matches!(bitpix, 8 | 16 | 32 | 64 | -32 | -64) {
|
||||
return Err(err(format!("invalid BITPIX {bitpix}")));
|
||||
}
|
||||
let ax = axes(&h)?;
|
||||
let (Some(&w), Some(&hgt)) = (ax.first(), ax.get(1)) else { return Err(err("the image has fewer than two axes")) };
|
||||
let w = u32::try_from(w).map_err(|_| CodecError::LimitExceeded(format!("width {w}")))?;
|
||||
let hgt = u32::try_from(hgt).map_err(|_| CodecError::LimitExceeded(format!("height {hgt}")))?;
|
||||
let planes = ax.iter().skip(2).try_fold(1u64, |a, &n| a.checked_mul(n)).ok_or_else(|| err("too many planes"))?;
|
||||
let (layout, nplanes) = if ax.get(2) == Some(&3) && planes == 3 { (ChannelLayout::Rgb, 3usize) } else { (ChannelLayout::Gray, 1) };
|
||||
|
||||
let bscale = h.float("BSCALE").unwrap_or(1.0);
|
||||
let bzero = h.float("BZERO").unwrap_or(0.0);
|
||||
let blank = if bitpix > 0 { h.int("BLANK") } else { None };
|
||||
let integral = bscale == 1.0 && bzero.fract() == 0.0;
|
||||
let candidate = match bitpix {
|
||||
8 if integral && bzero == 0.0 => SampleType::U8,
|
||||
8 | 16 if integral => SampleType::U16,
|
||||
_ => SampleType::F32,
|
||||
};
|
||||
limits.check(w, hgt, layout, candidate)?;
|
||||
|
||||
let bps = (bitpix.unsigned_abs() / 8) as usize;
|
||||
let plane_len = (w as usize).checked_mul(hgt as usize).ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{hgt} does not fit in memory")))?;
|
||||
let n = plane_len.checked_mul(nplanes).ok_or_else(|| CodecError::LimitExceeded("image does not fit in memory".into()))?;
|
||||
let need = n.checked_mul(bps).and_then(|l| l.checked_add(h.data_start)).ok_or_else(|| err("data size overflows"))?;
|
||||
let data = bytes.get(h.data_start..need).ok_or_else(|| err("truncated data"))?;
|
||||
|
||||
// Physical value of sample `i` (planar order); `None` for a blank or NaN pixel.
|
||||
let physical = |i: usize| -> Option<f64> {
|
||||
let s = data.get(i * bps..(i + 1) * bps)?;
|
||||
let raw = match bitpix {
|
||||
8 => s.first().map(|&v| v as i64)?,
|
||||
16 => i16::from_be_bytes([s[0], s[1]]) as i64,
|
||||
32 => i32::from_be_bytes([s[0], s[1], s[2], s[3]]) as i64,
|
||||
64 => i64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]),
|
||||
-32 => return Some(f32::from_be_bytes([s[0], s[1], s[2], s[3]]) as f64 * bscale + bzero).filter(|v| v.is_finite()),
|
||||
_ => return Some(f64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]) * bscale + bzero).filter(|v| v.is_finite()),
|
||||
};
|
||||
if blank == Some(raw) {
|
||||
return None;
|
||||
}
|
||||
Some(raw as f64 * bscale + bzero)
|
||||
};
|
||||
|
||||
let top_down = h.string("ROWORDER").is_some_and(|r| r.eq_ignore_ascii_case("TOP-DOWN"));
|
||||
let (wu, hu) = (w as usize, hgt as usize);
|
||||
// Planar, bottom-up (by default) → interleaved, top-down.
|
||||
let dest = |i: usize| -> usize {
|
||||
let (p, rem) = (i / plane_len, i % plane_len);
|
||||
let (y, x) = (rem / wu, rem % wu);
|
||||
let y = if top_down { y } else { hu - 1 - y };
|
||||
(y * wu + x) * nplanes + p
|
||||
};
|
||||
|
||||
let mut img = match candidate {
|
||||
SampleType::U8 => {
|
||||
let mut out = vec![0u8; n];
|
||||
for i in 0..n {
|
||||
out[dest(i)] = physical(i).unwrap_or(0.0) as u8;
|
||||
}
|
||||
Image::from_u8(w, hgt, layout, out)?
|
||||
}
|
||||
_ => {
|
||||
let mut out16 = if candidate == SampleType::U16 { Some(vec![0u16; n]) } else { None };
|
||||
if let Some(out) = out16.as_mut() {
|
||||
for i in 0..n {
|
||||
let v = physical(i).unwrap_or(0.0);
|
||||
if !(0.0..=65535.0).contains(&v) {
|
||||
// Signed data with negative values: fall back to float.
|
||||
out16 = None;
|
||||
break;
|
||||
}
|
||||
out[dest(i)] = v as u16;
|
||||
}
|
||||
}
|
||||
match out16 {
|
||||
Some(out) => Image::from_u16(w, hgt, layout, &out)?,
|
||||
None => {
|
||||
let max = (0..n).filter_map(physical).fold(0.0f64, f64::max);
|
||||
let white = if max <= 2.0 {
|
||||
1.0
|
||||
} else if max <= 65535.0 {
|
||||
65535.0
|
||||
} else {
|
||||
max
|
||||
};
|
||||
let mut out = vec![0f32; n];
|
||||
for i in 0..n {
|
||||
out[dest(i)] = (physical(i).unwrap_or(0.0) / white) as f32;
|
||||
}
|
||||
Image::from_f32(w, hgt, layout, &out)?
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if planes > nplanes as u64 {
|
||||
img.warnings.push(DecodeWarning::MorePages { total: u32::try_from(planes).ok() });
|
||||
}
|
||||
let mut text = Vec::new();
|
||||
for c in primary.iter().flat_map(|p| p.cards.iter()).chain(h.cards.iter()) {
|
||||
if !c.hierarch && (STRUCTURAL.contains(&c.key.as_str()) || is_naxis(&c.key)) {
|
||||
continue;
|
||||
}
|
||||
let entry = match &c.value {
|
||||
Some(Value::Str(s) | Value::Raw(s)) => (c.key.clone(), s.clone()),
|
||||
None if c.key.is_empty() && c.text.trim().is_empty() => continue,
|
||||
None => (if c.key.is_empty() { "COMMENT".to_string() } else { c.key.clone() }, c.text.trim().to_string()),
|
||||
};
|
||||
text.push(entry);
|
||||
}
|
||||
img.meta.text = text;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
fn is_naxis(key: &str) -> bool {
|
||||
key.strip_prefix("NAXIS").is_some_and(|d| d.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// A standard keyword: 1–8 characters from `A-Z 0-9 _ -`.
|
||||
fn standard_key(key: &str) -> bool {
|
||||
(1..=8).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn ascii(s: &str) -> String {
|
||||
s.chars().map(|c| if (' '..='~').contains(&c) { c } else { '?' }).collect()
|
||||
}
|
||||
|
||||
/// A FITS literal (number or logical) that can be written unquoted.
|
||||
fn literal(v: &str) -> bool {
|
||||
v == "T" || v == "F" || (v.replace(['D', 'd'], "E").parse::<f64>().is_ok_and(|f| f.is_finite()) && v.bytes().all(|b| b"0123456789+-.EeDd".contains(&b)))
|
||||
}
|
||||
|
||||
fn quoted(v: &str, room: usize) -> String {
|
||||
let mut s = String::from("'");
|
||||
for c in ascii(v).chars() {
|
||||
let add = if c == '\'' { 2 } else { 1 };
|
||||
// Keep room for the closing quote.
|
||||
if s.len() + add + 1 > room {
|
||||
break;
|
||||
}
|
||||
s.push(c);
|
||||
if c == '\'' {
|
||||
s.push('\'');
|
||||
}
|
||||
}
|
||||
// Strings are at least eight characters inside the quotes.
|
||||
while s.len() < 9 {
|
||||
s.push(' ');
|
||||
}
|
||||
s.push('\'');
|
||||
s
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
out: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn card(&mut self, text: &str) {
|
||||
let mut c: Vec<u8> = ascii(text).into_bytes();
|
||||
c.truncate(CARD);
|
||||
c.resize(CARD, b' ');
|
||||
self.out.extend_from_slice(&c);
|
||||
}
|
||||
|
||||
fn value(&mut self, key: &str, v: &str) {
|
||||
if literal(v) {
|
||||
self.card(&format!("{key:<8}= {v:>20}"));
|
||||
} else {
|
||||
self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10)));
|
||||
}
|
||||
}
|
||||
|
||||
fn commentary(&mut self, key: &str, text: &str) {
|
||||
let chars: Vec<char> = ascii(text).chars().collect();
|
||||
if chars.is_empty() {
|
||||
self.card(key);
|
||||
}
|
||||
for chunk in chars.chunks(CARD - 8) {
|
||||
self.card(&format!("{key:<8}{}", chunk.iter().collect::<String>()));
|
||||
}
|
||||
}
|
||||
|
||||
fn text(&mut self, key: &str, v: &str) {
|
||||
let upper = key.to_ascii_uppercase();
|
||||
if matches!(upper.as_str(), "COMMENT" | "HISTORY") {
|
||||
self.commentary(&upper, v);
|
||||
} else if standard_key(key) {
|
||||
if !STRUCTURAL.contains(&key) && !is_naxis(key) {
|
||||
self.value(key, v);
|
||||
}
|
||||
} else {
|
||||
// Long or mixed-case names (`Description`, `Software`): the HIERARCH convention,
|
||||
// when the card fits, else a comment.
|
||||
let name = ascii(key);
|
||||
let head = format!("HIERARCH {name} = ");
|
||||
let fits_key = !name.contains('=') && !name.trim().is_empty();
|
||||
let body = if literal(v) { v.to_string() } else { quoted(v, CARD.saturating_sub(head.len())) };
|
||||
if fits_key && head.len() + body.len() <= CARD && (literal(v) || body.ends_with('\'')) && quoted_whole(v, &body) {
|
||||
self.card(&format!("{head}{body}"));
|
||||
} else {
|
||||
self.commentary("COMMENT", &format!("{key} = {v}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `body` holds all of `v` (it was not cut short to fit the card).
|
||||
fn quoted_whole(v: &str, body: &str) -> bool {
|
||||
literal(v) || body.trim_matches('\'').trim_end().replace("''", "'") == ascii(v).trim_end()
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let layout = img.layout();
|
||||
let nc = match layout {
|
||||
ChannelLayout::Gray => 1,
|
||||
ChannelLayout::Rgb => 3,
|
||||
l => return Err(CodecError::encode(F, format!("FITS cannot store {l:?}"))),
|
||||
};
|
||||
let bitpix = match img.sample_type() {
|
||||
SampleType::U8 => 8,
|
||||
SampleType::U16 => 16,
|
||||
SampleType::F32 => -32,
|
||||
s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))),
|
||||
};
|
||||
let mut wr = Writer { out: Vec::new() };
|
||||
wr.value("SIMPLE", "T");
|
||||
wr.value("BITPIX", &bitpix.to_string());
|
||||
wr.value("NAXIS", if nc == 3 { "3" } else { "2" });
|
||||
wr.value("NAXIS1", &w.to_string());
|
||||
wr.value("NAXIS2", &h.to_string());
|
||||
if nc == 3 {
|
||||
wr.value("NAXIS3", "3");
|
||||
}
|
||||
if bitpix == 16 {
|
||||
wr.value("BZERO", "32768");
|
||||
wr.value("BSCALE", "1");
|
||||
}
|
||||
wr.value("ROWORDER", "BOTTOM-UP");
|
||||
if opts.embed_metadata {
|
||||
for (k, v) in &img.meta.text {
|
||||
wr.text(k, v);
|
||||
}
|
||||
}
|
||||
wr.card("END");
|
||||
let mut out = wr.out;
|
||||
out.resize(out.len().div_ceil(BLOCK) * BLOCK, b' ');
|
||||
|
||||
// Planar, bottom row first.
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let idx = |p: usize, y: usize, x: usize| ((hu - 1 - y) * wu + x) * nc + p;
|
||||
match bitpix {
|
||||
8 => {
|
||||
let d = img.data();
|
||||
for p in 0..nc {
|
||||
for y in 0..hu {
|
||||
for x in 0..wu {
|
||||
out.push(d[idx(p, y, x)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16 => {
|
||||
let d = img.to_u16_samples().unwrap_or_default();
|
||||
for p in 0..nc {
|
||||
for y in 0..hu {
|
||||
for x in 0..wu {
|
||||
out.extend_from_slice(&((d[idx(p, y, x)] as i32 - 32768) as i16).to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let d = img.to_f32_samples().unwrap_or_default();
|
||||
for p in 0..nc {
|
||||
for y in 0..hu {
|
||||
for x in 0..wu {
|
||||
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.resize(out.len().div_ceil(BLOCK) * BLOCK, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn header(cards: &[&str]) -> Vec<u8> {
|
||||
let mut w = Writer { out: Vec::new() };
|
||||
for c in cards {
|
||||
w.card(c);
|
||||
}
|
||||
w.card("END");
|
||||
let mut out = w.out;
|
||||
out.resize(out.len().div_ceil(BLOCK) * BLOCK, b' ');
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_u16_with_bzero_reads_exactly_and_bottom_up() {
|
||||
let mut b = header(&[
|
||||
"SIMPLE = T",
|
||||
"BITPIX = 16",
|
||||
"NAXIS = 2",
|
||||
"NAXIS1 = 2",
|
||||
"NAXIS2 = 2",
|
||||
"BZERO = 32768",
|
||||
"OBJECT = 'M 31 '",
|
||||
"EXPTIME = 300.0 / seconds",
|
||||
]);
|
||||
for v in [0u16, 1000, 40000, 65535] {
|
||||
b.extend_from_slice(&((v as i32 - 32768) as i16).to_be_bytes());
|
||||
}
|
||||
b.resize(b.len().div_ceil(BLOCK) * BLOCK, 0);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.sample_type(), SampleType::U16);
|
||||
// The first stored row is the bottom one.
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![40000, 65535, 0, 1000]);
|
||||
assert!(img.meta.text.contains(&("OBJECT".into(), "M 31".into())));
|
||||
assert!(img.meta.text.contains(&("EXPTIME".into(), "300.0".into())));
|
||||
assert!(!img.meta.text.iter().any(|(k, _)| k == "BZERO" || k == "NAXIS1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roworder_top_down_is_honoured() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 2", "ROWORDER= 'TOP-DOWN'"]);
|
||||
b.extend_from_slice(&[10, 20]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[10, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_float_in_adu_is_scaled_by_65535() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = -32", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
|
||||
for v in [65535.0f32, f32::NAN] {
|
||||
b.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_negative_data_falls_back_to_float() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
|
||||
for v in [-100i16, 1000] {
|
||||
b.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.sample_type(), SampleType::F32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_extension_after_empty_primary() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0", "EXTEND = T", "TELESCOP= 'Seestar S50'"]);
|
||||
b.extend(header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1", "PCOUNT = 0", "GCOUNT = 1"]));
|
||||
b.push(42);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[42]);
|
||||
assert!(img.meta.text.contains(&("TELESCOP".into(), "Seestar S50".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fpack_is_refused_as_unsupported() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
|
||||
b.extend(header(&["XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 0", "NAXIS2 = 0", "ZIMAGE = T"]));
|
||||
b.extend(header(&["XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 1", "NAXIS1 = 0"]));
|
||||
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
/// Hostile headers: absurd or negative axes, bad BITPIX, overflowing sizes, missing END,
|
||||
/// a long chain of empty HDUs and declared data past the end all error, never panic or
|
||||
/// allocate the declared size.
|
||||
#[test]
|
||||
fn hostile_headers_error_without_panicking() {
|
||||
let base = ["SIMPLE = T", "BITPIX = 16"];
|
||||
let cases: &[&[&str]] = &[
|
||||
&["NAXIS = 2", "NAXIS1 = 4294967296", "NAXIS2 = 1"],
|
||||
&["NAXIS = 2", "NAXIS1 = -5", "NAXIS2 = 1"],
|
||||
&["NAXIS = 2", "NAXIS1 = 2000000", "NAXIS2 = 2000000"],
|
||||
&["NAXIS = 3", "NAXIS1 = 65536", "NAXIS2 = 65536", "NAXIS3 = 9223372036854775807"],
|
||||
&["NAXIS = 1000"],
|
||||
&["NAXIS = -1"],
|
||||
&["NAXIS = 2", "NAXIS1 = 0", "NAXIS2 = 0"],
|
||||
&["NAXIS = 2", "NAXIS1 = 1.5D300", "NAXIS2 = 1"],
|
||||
&["NAXIS = 2"],
|
||||
&["NAXIS = 2", "NAXIS1 = 100", "NAXIS2 = 100"],
|
||||
];
|
||||
for extra in cases {
|
||||
let cards: Vec<&str> = base.iter().chain(extra.iter()).copied().collect();
|
||||
let b = header(&cards);
|
||||
assert!(decode(&b, &Limits::default()).is_err(), "{extra:?}");
|
||||
}
|
||||
for bitpix in ["0", "7", "-16", "128", "'X'"] {
|
||||
let card = format!("BITPIX = {bitpix}");
|
||||
let b = header(&["SIMPLE = T", &card, "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1"]);
|
||||
assert!(decode(&b, &Limits::default()).is_err(), "BITPIX {bitpix}");
|
||||
}
|
||||
// No END card: the header runs off the end of the file.
|
||||
let mut w = Writer { out: Vec::new() };
|
||||
for c in ["SIMPLE = T", "BITPIX = 8", "NAXIS = 2"] {
|
||||
w.card(c);
|
||||
}
|
||||
assert!(decode(&w.out, &Limits::default()).is_err());
|
||||
// An empty primary followed by many empty extensions: bounded walk, then "no image".
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
|
||||
for _ in 0..(MAX_HDUS + 5) {
|
||||
b.extend(header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 0"]));
|
||||
}
|
||||
assert!(decode(&b, &Limits::default()).is_err());
|
||||
// An extension whose declared data size overflows.
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
|
||||
b.extend(header(&[
|
||||
"XTENSION= 'BINTABLE'",
|
||||
"BITPIX = 64",
|
||||
"NAXIS = 2",
|
||||
"NAXIS1 = 9223372036854775807",
|
||||
"NAXIS2 = 4",
|
||||
"PCOUNT = 0",
|
||||
"GCOUNT = 1",
|
||||
]));
|
||||
assert!(decode(&b, &Limits::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_planes_warn() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 3", "NAXIS1 = 1", "NAXIS2 = 1", "NAXIS3 = 4"]);
|
||||
b.extend_from_slice(&[1, 2, 3, 4]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.warnings, vec![DecodeWarning::MorePages { total: Some(4) }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_with_quotes_and_hierarch_round_trip() {
|
||||
let mut w = Writer { out: Vec::new() };
|
||||
w.text("OBSERVER", "O'Brien");
|
||||
w.text("Software", "photocraft 1.0");
|
||||
w.text("HISTORY", "stacked");
|
||||
let cards: Vec<Card> = w.out.chunks(CARD).map(parse_card).collect();
|
||||
assert_eq!(cards[0].value, Some(Value::Str("O'Brien".into())));
|
||||
assert!(cards[1].hierarch && cards[1].key == "Software");
|
||||
assert_eq!(cards[1].value, Some(Value::Str("photocraft 1.0".into())));
|
||||
assert_eq!((cards[2].key.as_str(), cards[2].text.as_str()), ("HISTORY", "stacked"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Every frame of an animated GIF. [`crate::decode`] keeps the first frame only; this walks the
|
||||
//! whole animation for callers that can hold it (video layers).
|
||||
//!
|
||||
//! The `image` crate composites each frame onto the logical screen and applies the disposal
|
||||
//! methods, so every returned frame is a full, self-contained picture.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::{AnimationDecoder, ImageDecoder};
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image};
|
||||
use crate::options::DecodeOptions;
|
||||
|
||||
/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise
|
||||
/// ask for millions of them while staying inside the byte budget.
|
||||
pub const MAX_GIF_FRAMES: usize = 100_000;
|
||||
|
||||
/// One composited frame of an animation and how long it stays on screen.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AnimationFrame {
|
||||
pub image: Image,
|
||||
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a
|
||||
/// multiple of 10 (0 when the file gives none).
|
||||
pub delay_ms: u32,
|
||||
}
|
||||
|
||||
/// Decodes every frame of a GIF, each as a full RGBA8 image of the logical screen size.
|
||||
///
|
||||
/// The first frame must decode. If a later frame is damaged the frames before it are returned
|
||||
/// and the first one carries [`DecodeWarning::Truncated`]. Dimensions, the frame count
|
||||
/// ([`MAX_GIF_FRAMES`]) and the total decoded bytes are checked against `opts.limits` before
|
||||
/// each frame is kept.
|
||||
pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
|
||||
let limits = &opts.limits;
|
||||
let map = |e: image::ImageError| match e {
|
||||
image::ImageError::Limits(l) => CodecError::LimitExceeded(l.to_string()),
|
||||
image::ImageError::Unsupported(u) => CodecError::unsupported(Format::Gif, u.to_string()),
|
||||
e => CodecError::malformed(Format::Gif, e),
|
||||
};
|
||||
let mut decoder = GifDecoder::new(Cursor::new(bytes)).map_err(map)?;
|
||||
let mut il = image::Limits::default();
|
||||
il.max_image_width = Some(limits.max_width);
|
||||
il.max_image_height = Some(limits.max_height);
|
||||
il.max_alloc = Some(limits.max_alloc);
|
||||
decoder.set_limits(il).map_err(map)?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
limits.check_bytes(w, h, 4)?;
|
||||
let frame_bytes = u64::from(w) * u64::from(h) * 4;
|
||||
|
||||
let mut out: Vec<AnimationFrame> = Vec::new();
|
||||
let mut damaged = false;
|
||||
for frame in decoder.into_frames() {
|
||||
let frame = match frame {
|
||||
Ok(f) => f,
|
||||
Err(e) if out.is_empty() => return Err(map(e)),
|
||||
Err(_) => {
|
||||
damaged = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if out.len() >= MAX_GIF_FRAMES {
|
||||
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
|
||||
}
|
||||
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
|
||||
}
|
||||
let (numer, denom) = frame.delay().numer_denom_ms();
|
||||
let delay_ms = numer.checked_div(denom).unwrap_or(0);
|
||||
let rgba = frame.into_buffer();
|
||||
out.push(AnimationFrame { image: Image::from_u8(w, h, ChannelLayout::Rgba, rgba.into_raw())?, delay_ms });
|
||||
}
|
||||
if damaged && let Some(first) = out.first_mut() {
|
||||
first.image.warnings.push(DecodeWarning::Truncated { format: Format::Gif });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
pub(crate) mod fits;
|
||||
pub(crate) mod gif_frames;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod png;
|
||||
|
||||
@@ -521,8 +521,16 @@ fn adler32_combine(a1: u32, a2: u32, len2: usize) -> u32 {
|
||||
}
|
||||
|
||||
/// Writes an 8-bit palette PNG (PNG-8): `indices` row-major, `palette` RGB entries (≤ 256),
|
||||
/// optional fully transparent entry (`tRNS`). Used for Indexed Color documents.
|
||||
pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>) -> Result<Vec<u8>, CodecError> {
|
||||
/// optional fully transparent entry (`tRNS`) and optional resolution in dpi (`pHYs`, written when
|
||||
/// both axes are positive and finite). Used for Indexed Color documents.
|
||||
pub fn encode_indexed(
|
||||
width: u32,
|
||||
height: u32,
|
||||
indices: &[u8],
|
||||
palette: &[[u8; 3]],
|
||||
transparent: Option<u8>,
|
||||
dpi: Option<(f32, f32)>,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
|
||||
return Err(CodecError::InvalidImage("index data does not match the size".into()));
|
||||
}
|
||||
@@ -538,6 +546,15 @@ pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3
|
||||
trns[usize::from(t)] = 0;
|
||||
info.trns = Some(trns.into());
|
||||
}
|
||||
if let Some((x, y)) = dpi
|
||||
&& x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x > 0.0
|
||||
&& y > 0.0
|
||||
{
|
||||
info.pixel_dims =
|
||||
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
|
||||
@@ -556,14 +573,14 @@ mod indexed_tests {
|
||||
fn png8_roundtrips_through_the_decoder() {
|
||||
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
|
||||
let idx = [0u8, 1, 2, 1, 0, 2];
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2)).unwrap();
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
|
||||
let img = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(img.dimensions(), (3, 2));
|
||||
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
|
||||
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
|
||||
assert_eq!(px.data()[11], 0, "transparent entry");
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,17 +79,43 @@ fn sample_for_maxval(maxval: u32) -> Result<SampleType, CodecError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an image from raw integer values in `[0, maxval]`.
|
||||
fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u32>) -> Result<Image, CodecError> {
|
||||
let sample = sample_for_maxval(maxval)?;
|
||||
if values.iter().any(|&v| v > maxval) {
|
||||
return Err(err("sample exceeds maxval"));
|
||||
/// The decoded raster, built straight in the output representation. It is the only buffer sized
|
||||
/// by the header, so `Limits::check` on the output budgets all of it: no wider scratch buffer
|
||||
/// (such as one `u32` per sample) sits beside it (#2938).
|
||||
struct Samples {
|
||||
maxval: u32,
|
||||
sample: SampleType,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Samples {
|
||||
/// Reserve room for `cap` samples (callers bound `cap` by the file length where the header
|
||||
/// alone could overstate it).
|
||||
fn with_capacity(maxval: u32, cap: usize) -> Result<Self, CodecError> {
|
||||
let sample = sample_for_maxval(maxval)?;
|
||||
let len = cap.checked_mul(sample.bytes()).ok_or_else(|| CodecError::LimitExceeded(format!("{cap} samples do not fit in memory")))?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} bytes for the image")))?;
|
||||
Ok(Samples { maxval, sample, bytes })
|
||||
}
|
||||
match (sample, maxval) {
|
||||
(SampleType::U8, 255) => Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()),
|
||||
(SampleType::U16, 65535) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| v as u16).collect::<Vec<_>>()),
|
||||
(SampleType::U8, m) => Image::from_u8(w, h, layout, values.into_iter().map(|v| ((v * 255 + m / 2) / m) as u8).collect()),
|
||||
(_, m) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).collect::<Vec<_>>()),
|
||||
|
||||
/// Append one raw value in `[0, maxval]`, rescaled to the full range of the output sample.
|
||||
fn push(&mut self, v: u32) -> Result<(), CodecError> {
|
||||
let m = self.maxval;
|
||||
if v > m {
|
||||
return Err(err("sample exceeds maxval"));
|
||||
}
|
||||
match (self.sample, m) {
|
||||
(SampleType::U8, 255) => self.bytes.push(v as u8),
|
||||
(SampleType::U8, m) => self.bytes.push(((v * 255 + m / 2) / m) as u8),
|
||||
(_, 65535) => self.bytes.extend_from_slice(&(v as u16).to_ne_bytes()),
|
||||
(_, m) => self.bytes.extend_from_slice(&(((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).to_ne_bytes()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_image(self, w: u32, h: u32, layout: ChannelLayout) -> Result<Image, CodecError> {
|
||||
Image::from_raw(w, h, layout, self.sample, self.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,18 +128,24 @@ fn sample_count(w: u32, h: u32, channels: usize) -> Result<usize, CodecError> {
|
||||
.ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{h}x{channels} samples do not fit in memory")))
|
||||
}
|
||||
|
||||
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecError> {
|
||||
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Samples, CodecError> {
|
||||
let mut out = Samples::with_capacity(maxval, n)?;
|
||||
if maxval < 256 {
|
||||
if data.len() < n {
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
Ok(data[..n].iter().map(|&v| v as u32).collect())
|
||||
} else {
|
||||
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
|
||||
let Some(raw) = data.get(..n) else {
|
||||
return Err(err("truncated raster"));
|
||||
};
|
||||
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
|
||||
for &v in raw {
|
||||
out.push(v as u32)?;
|
||||
}
|
||||
} else {
|
||||
let Some(raw) = n.checked_mul(2).and_then(|len| data.get(..len)) else {
|
||||
return Err(err("truncated raster"));
|
||||
};
|
||||
for c in raw.as_chunks::<2>().0 {
|
||||
out.push(u16::from_be_bytes(*c) as u32)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
@@ -163,19 +195,19 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
|
||||
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
|
||||
let n = sample_count(w, h, layout.channels())?;
|
||||
let values = if matches!(kind, b'5' | b'6') {
|
||||
let samples = if matches!(kind, b'5' | b'6') {
|
||||
r.single_ws()?;
|
||||
read_binary(r.rest(), n, maxval)?
|
||||
} else {
|
||||
// Each ASCII sample is a digit and a separator (the last may lack one), so the
|
||||
// file, not the header, bounds the reservation (#1110).
|
||||
let mut v = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
|
||||
let mut v = Samples::with_capacity(maxval, n.min(r.rest().len() / 2 + 1))?;
|
||||
for _ in 0..n {
|
||||
v.push(r.uint()?);
|
||||
v.push(r.uint()?)?;
|
||||
}
|
||||
v
|
||||
};
|
||||
from_values(w, h, layout, maxval, values)
|
||||
samples.into_image(w, h, layout)
|
||||
}
|
||||
b'7' => decode_pam(&mut r, limits),
|
||||
b'F' | b'f' => {
|
||||
@@ -195,17 +227,22 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
let le = scale < 0.0;
|
||||
let mut out = vec![0f32; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
// Write native-endian bytes straight into the output buffer: a separate `Vec<f32>`
|
||||
// would double the budgeted allocation (#2938).
|
||||
let row_bytes = row * 4;
|
||||
let mut out = vec![0u8; row_bytes * h as usize];
|
||||
for (y, src) in data.chunks_exact(row_bytes).take(h as usize).enumerate() {
|
||||
// PFM rows are stored bottom-to-top.
|
||||
let dst = (h as usize - 1 - y) * row;
|
||||
for i in 0..row {
|
||||
let o = (y * row + i) * 4;
|
||||
let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]];
|
||||
out[dst + i] = if le { f32::from_le_bytes(b4) } else { f32::from_be_bytes(b4) };
|
||||
let dst_start = (h as usize - 1 - y) * row_bytes;
|
||||
let Some(dst) = out.get_mut(dst_start..dst_start + row_bytes) else {
|
||||
return Err(err("truncated raster"));
|
||||
};
|
||||
for (d, s) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) {
|
||||
let v = if le { f32::from_le_bytes(*s) } else { f32::from_be_bytes(*s) };
|
||||
*d = v.to_ne_bytes();
|
||||
}
|
||||
}
|
||||
Image::from_f32(w, h, layout, &out)
|
||||
Image::from_raw(w, h, layout, SampleType::F32, out)
|
||||
}
|
||||
_ => Err(err("unknown Netpbm subtype")),
|
||||
}
|
||||
@@ -250,8 +287,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
||||
};
|
||||
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
|
||||
let n = sample_count(w, h, layout.channels())?;
|
||||
let values = read_binary(r.rest(), n, maxval)?;
|
||||
from_values(w, h, layout, maxval, values)
|
||||
read_binary(r.rest(), n, maxval)?.into_image(w, h, layout)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
|
||||