mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 15:14:11 -04:00
Compare commits
224
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e40d9b1f1 | ||
|
|
b436950ae3 | ||
|
|
8948511eeb | ||
|
|
2a2459f57a | ||
|
|
0675a253dd | ||
|
|
f7be9fbe2c | ||
|
|
57452bfd22 | ||
|
|
f9479063be | ||
|
|
c887ed94cc | ||
|
|
727daf5d67 | ||
|
|
5c94c5e2b6 | ||
|
|
25f211500b | ||
|
|
a20c63521d | ||
|
|
684f85e2e5 | ||
|
|
ec64eb773c | ||
|
|
9d8a78a17e | ||
|
|
0c976f42d5 | ||
|
|
a508ee9014 | ||
|
|
4b3da49486 | ||
|
|
2b8be01d6f | ||
|
|
b62664ad33 | ||
|
|
6b926cdecd | ||
|
|
8ce0684759 | ||
|
|
4b80b031bc | ||
|
|
38d23b621f | ||
|
|
385e8979de | ||
|
|
080f2c4c8d | ||
|
|
f15fb4ee6e | ||
|
|
48bfaa9d0d | ||
|
|
9a9eb013db | ||
|
|
03ffca0d54 | ||
|
|
41e9c742dc | ||
|
|
15af216bd1 | ||
|
|
e1cf321183 | ||
|
|
b6f5df3b3f | ||
|
|
6ee3e4f752 | ||
|
|
83ace1138e | ||
|
|
9ef9992878 | ||
|
|
1b24b5212b | ||
|
|
e9142754a4 | ||
|
|
4a0a8d0794 | ||
|
|
51a0816f99 | ||
|
|
165e11ad6a | ||
|
|
1a052d6115 | ||
|
|
bd632e7eb3 | ||
|
|
8302f57190 | ||
|
|
d9a0f38280 | ||
|
|
7080e6c56a | ||
|
|
2baa95d684 | ||
|
|
9e88c65d33 | ||
|
|
2cbb13a79d | ||
|
|
1353917f15 | ||
|
|
fb924dad16 | ||
|
|
a1f4387e63 | ||
|
|
702b4aa6b0 | ||
|
|
ac57c17b14 | ||
|
|
151afe47b6 | ||
|
|
3ad26e3b51 | ||
|
|
f46c462129 | ||
|
|
b70bf524ea | ||
|
|
6c7d01a0f3 | ||
|
|
107ffe9127 | ||
|
|
3f637410cb | ||
|
|
fee9ff9613 | ||
|
|
6103da5278 | ||
|
|
33c58b5ca0 | ||
|
|
9d46c8ea80 | ||
|
|
1e1f78325c | ||
|
|
35ceaefce2 | ||
|
|
135ae8cf96 | ||
|
|
06ec4044ea | ||
|
|
ac5338360f | ||
|
|
7a45adc913 | ||
|
|
12460d0345 | ||
|
|
9a448fe23b | ||
|
|
2050ca083e | ||
|
|
0aabb48687 | ||
|
|
a62b6473f3 | ||
|
|
2cdff3afb9 | ||
|
|
681dee8c8f | ||
|
|
6c4010cfc4 | ||
|
|
9aef02cac3 | ||
|
|
782ea69f35 | ||
|
|
8d8ea584b8 | ||
|
|
7f486d723f | ||
|
|
3bea3f31c5 | ||
|
|
3a3984075a | ||
|
|
09b6572578 | ||
|
|
43c86ef645 | ||
|
|
9270a31f24 | ||
|
|
0c40ec17c6 | ||
|
|
fe4243ba1f | ||
|
|
d5923e49db | ||
|
|
fc00f1e5bc | ||
|
|
09f21d99ca | ||
|
|
e79dcb6f7f | ||
|
|
e70deecbe5 | ||
|
|
9e6491e5f5 | ||
|
|
03b901cdde | ||
|
|
d7df9d8aa6 | ||
|
|
47f9306fd0 | ||
|
|
7055f0c51b | ||
|
|
2cd470c08e | ||
|
|
b07a2e5b43 | ||
|
|
b9a24da799 | ||
|
|
09bc2ac0c4 | ||
|
|
e77325bd4a | ||
|
|
d4086ba11a | ||
|
|
45bedb0962 | ||
|
|
3d23a708c2 | ||
|
|
8ebca04aa9 | ||
|
|
fa0defc56b | ||
|
|
90df9cfc15 | ||
|
|
786a863f9c | ||
|
|
a4e96991f1 | ||
|
|
b45dfde498 | ||
|
|
b5eed81a25 | ||
|
|
8aecbffb5c | ||
|
|
b4f9d56c93 | ||
|
|
cc5de414dc | ||
|
|
a8afec4455 | ||
|
|
fbe5f80ba4 | ||
|
|
7dc1575efc | ||
|
|
0cb27712c8 | ||
|
|
bf545e0e81 | ||
|
|
08e3e610c0 | ||
|
|
047aeb747a | ||
|
|
50d52a0848 | ||
|
|
2c02d41eaf | ||
|
|
d07eecf66f | ||
|
|
395a52e7f1 | ||
|
|
45d0f4ad93 | ||
|
|
6422219226 | ||
|
|
d91ab25772 | ||
|
|
bd7d47bf0f | ||
|
|
48bd08150e | ||
|
|
5856866f89 | ||
|
|
da9adf82bc | ||
|
|
7313e08141 | ||
|
|
25d198ceb1 | ||
|
|
0b7cd68f8b | ||
|
|
68831a4336 | ||
|
|
b41442c466 | ||
|
|
6237f6e46e | ||
|
|
9f0c049b53 | ||
|
|
3c3dd881ce | ||
|
|
eb2fb2634f | ||
|
|
60224d3fb7 | ||
|
|
06b0ad7af0 | ||
|
|
51d85c1170 | ||
|
|
8a8371395b | ||
|
|
d9413cec0f | ||
|
|
2628b0dd3d | ||
|
|
2868220032 | ||
|
|
7eb3b2e072 | ||
|
|
8f0f95959d | ||
|
|
13b0d856b6 | ||
|
|
e30b121969 | ||
|
|
bd1322a5e4 | ||
|
|
faaa42db95 | ||
|
|
1df68e9a5d | ||
|
|
29c280a2c6 | ||
|
|
4337a6227a | ||
|
|
8a5e89faa6 | ||
|
|
a312c3d0c7 | ||
|
|
4fdb71dfc3 | ||
|
|
72e2eb9644 | ||
|
|
2051c0caf4 | ||
|
|
5824e3afeb | ||
|
|
f373fdb52f | ||
|
|
d38eefc8f7 | ||
|
|
7b3d131bc8 | ||
|
|
cb00e2921b | ||
|
|
d2576b27eb | ||
|
|
870741a5cd | ||
|
|
1cb5af9218 | ||
|
|
9cf7c42f1d | ||
|
|
533566c5ad | ||
|
|
778e8e187d | ||
|
|
9ae984c0ae | ||
|
|
a0997942d3 | ||
|
|
93bd44899b | ||
|
|
f697e43ef7 | ||
|
|
f49b5a4853 | ||
|
|
58bfb4ee8c | ||
|
|
c8e0a34792 | ||
|
|
14207b76c8 | ||
|
|
3c6310e559 | ||
|
|
c0a7f8e7e4 | ||
|
|
6119c00962 | ||
|
|
b926677549 | ||
|
|
3443833532 | ||
|
|
55054b5fcc | ||
|
|
7930dd0adf | ||
|
|
2620460267 | ||
|
|
ba9272be7d | ||
|
|
0940f82d34 | ||
|
|
fbcd0c238e | ||
|
|
0da7d4b0f5 | ||
|
|
90a46d3f89 | ||
|
|
c161eaec73 | ||
|
|
0349b0c0f1 | ||
|
|
909efc0f6d | ||
|
|
3b7870bee1 | ||
|
|
a0d49de047 | ||
|
|
f8563856a3 | ||
|
|
5a6dd79852 | ||
|
|
b3c44cf070 | ||
|
|
abd6f6e591 | ||
|
|
edc441a8d4 | ||
|
|
321e986f7b | ||
|
|
4b76aff35d | ||
|
|
7544b649f2 | ||
|
|
dd2f566daa | ||
|
|
ae9e1896cf | ||
|
|
48e6e668b9 | ||
|
|
b6e9ec97e5 | ||
|
|
c046337ddf | ||
|
|
f091c7fbf0 | ||
|
|
4e2a83ddbd | ||
|
|
e8677ff851 | ||
|
|
44bf13e890 | ||
|
|
7ee4bb2858 | ||
|
|
eaf92dcd9d |
No files matched your search
@@ -51,11 +51,35 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only.
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
@@ -114,11 +138,13 @@ jobs:
|
||||
target:
|
||||
- { crate: psd, name: parse }
|
||||
- { crate: psd, name: descriptor }
|
||||
- { crate: psd, name: image_source_data }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
- { crate: codecs, name: decode_tiff }
|
||||
- { crate: codecs, name: decode_exr }
|
||||
- { crate: codecs, name: decode_heif }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -51,8 +51,13 @@ jobs:
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
|
||||
# device at once, so the GPU integration tests run one at a time here.
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
|
||||
@@ -34,7 +34,9 @@ jobs:
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
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: |
|
||||
|
||||
@@ -30,6 +30,10 @@ env:
|
||||
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:
|
||||
@@ -70,6 +74,12 @@ jobs:
|
||||
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
|
||||
@@ -90,7 +100,18 @@ jobs:
|
||||
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
|
||||
@@ -112,11 +133,20 @@ jobs:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
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 }}
|
||||
@@ -141,6 +171,8 @@ jobs:
|
||||
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:
|
||||
@@ -167,12 +199,18 @@ jobs:
|
||||
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
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
@@ -184,6 +222,9 @@ jobs:
|
||||
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: |
|
||||
@@ -194,6 +235,9 @@ jobs:
|
||||
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:
|
||||
@@ -252,6 +296,12 @@ jobs:
|
||||
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
|
||||
@@ -274,6 +324,8 @@ jobs:
|
||||
# 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$'
|
||||
@@ -308,6 +360,8 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: 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'
|
||||
@@ -8,3 +8,4 @@ target/
|
||||
/secrets
|
||||
/temp
|
||||
/book/output
|
||||
/craft-fonts
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md: guide for AI agents and contributors
|
||||
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
|
||||
## 1. Orientation (5 minutes)
|
||||
|
||||
@@ -57,7 +57,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
|
||||
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
|
||||
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
|
||||
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
|
||||
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
|
||||
|
||||
@@ -10,6 +10,9 @@ next to them.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German 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/ko.tsv` | Korean 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) |
|
||||
| `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`, `eraser-background.svg` and `eraser-magic.svg` (109 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) |
|
||||
@@ -20,6 +23,19 @@ next to them.
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
|
||||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
|
||||
from it is committed here. Per-file authors, sources and licences:
|
||||
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
| Font (embedded at build time) | Use | License |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
|
||||
|
||||
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
|
||||
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
|
||||
(CC0-1.0); they are not separate files.
|
||||
@@ -30,6 +46,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
|---|---|---|---|---|
|
||||
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
|
||||
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
|
||||
|
||||
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
|
||||
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
|
||||
@@ -58,6 +75,7 @@ next to the files:
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+118
-27
@@ -8,6 +8,8 @@ version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
dependencies = [
|
||||
"enumn",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -130,6 +132,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
@@ -575,6 +578,9 @@ name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
@@ -1182,6 +1188,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1210,6 +1217,8 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"ron",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1234,6 +1243,8 @@ dependencies = [
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
"profiling",
|
||||
"ron",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"web-sys",
|
||||
@@ -1275,6 +1286,7 @@ dependencies = [
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"winit",
|
||||
@@ -1343,6 +1355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1392,6 +1405,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumn"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.36.2"
|
||||
@@ -1410,6 +1434,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-general-category",
|
||||
@@ -1616,6 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2004,6 +2030,15 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -3386,13 +3421,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3407,13 +3445,16 @@ dependencies = [
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3425,7 +3466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
@@ -3447,7 +3488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3460,7 +3501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -3468,7 +3509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -3476,8 +3517,10 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
@@ -3486,7 +3529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -3494,7 +3537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -3509,7 +3552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3520,7 +3563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3550,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3568,14 +3611,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -3590,9 +3633,16 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
@@ -3616,14 +3666,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3634,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3649,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -3660,7 +3710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3670,7 +3720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
@@ -3679,7 +3729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -3691,7 +3741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -3702,7 +3752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -3716,7 +3766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
@@ -3725,6 +3775,7 @@ dependencies = [
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
@@ -3735,6 +3786,7 @@ dependencies = [
|
||||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
@@ -3742,11 +3794,12 @@ dependencies = [
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -3757,7 +3810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4392,6 +4445,20 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
@@ -4707,6 +4774,9 @@ name = "smallvec"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
@@ -4885,6 +4955,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5168,6 +5247,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -6174,6 +6259,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
@@ -6285,7 +6376,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
|
||||
@@ -16,3 +16,8 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
|
||||
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
|
||||
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
|
||||
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
|
||||
ship with the package as OFL-<family>.txt.
|
||||
@@ -198,7 +198,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, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
@@ -231,6 +231,9 @@ photocraft-cli run wave.psd \
|
||||
# Apply one action list to a folder of images
|
||||
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
|
||||
|
||||
# Every subcommand explains itself
|
||||
photocraft-cli batch --help
|
||||
|
||||
# Let an agent drive it over MCP (headless, or bridged to the running app)
|
||||
photocraft-cli mcp
|
||||
```
|
||||
@@ -255,6 +258,13 @@ cargo run --release -p photocraft -- image.psd # the desktop app
|
||||
cargo test --workspace # the test suite
|
||||
```
|
||||
|
||||
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
|
||||
```
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
|
||||
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
@@ -264,6 +274,16 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
|
||||
flatpak run ai.storyteller.photocraft
|
||||
```
|
||||
|
||||
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
```sh
|
||||
ditto -x -k photocraft-cli-<version>-macos-universal.zip .
|
||||
spctl --assess --type install -vv photocraft-cli-<version>-macos-universal/photocraft-cli
|
||||
# ... accepted, source=Notarized Developer ID
|
||||
```
|
||||
|
||||
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
|
||||
|
||||
```sh
|
||||
@@ -304,7 +324,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -358,5 +378,6 @@ Forks and modified versions must remove them.
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
ArtCraft
|
||||
[](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
|
||||
+1
-1
@@ -49,4 +49,4 @@ General bugs without a security impact should use the project's normal issue and
|
||||
|
||||
## Current limitations
|
||||
|
||||
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
|
||||
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. Authentication does not provide per-client or per-tool capabilities, so an authenticated client still receives the exposed control surface. Remote filesystem methods use separately configured read and write workspace roots, and engine commands that still use ambient filesystem paths are denied at the automation boundary. Automation can change individual safe preferences, but whole-preferences updates and filesystem-bearing preference sections are refused; applying the Preferences dialog through automation is also refused. JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
|
||||
@@ -5,6 +5,11 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
+111
-68
@@ -14,15 +14,19 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
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>]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
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>]
|
||||
Apply an action list ([{\"command\": id, \"params\": {…}}, …]) to every image in a directory.
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
|
||||
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.
|
||||
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>]
|
||||
@@ -33,8 +37,12 @@ USAGE:
|
||||
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
|
||||
[--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, engine.commands, doc.open/new/save/inspect/render/
|
||||
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
|
||||
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
|
||||
(docs/control-protocol.md#headless-server).
|
||||
|
||||
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
|
||||
an error.
|
||||
";
|
||||
|
||||
struct Args {
|
||||
@@ -42,43 +50,67 @@ struct Args {
|
||||
flags: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
const VALUE_FLAGS: &[&str] = &[
|
||||
"--format",
|
||||
"--quality",
|
||||
"--new",
|
||||
"--cmd",
|
||||
"--params",
|
||||
"--out",
|
||||
"--actions",
|
||||
"--in",
|
||||
"--filter",
|
||||
"--bridge",
|
||||
"--port",
|
||||
"--control-token",
|
||||
"--control-token-file",
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
|
||||
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
|
||||
struct Subcommand {
|
||||
name: &'static str,
|
||||
values: &'static [&'static str],
|
||||
bare: &'static [&'static str],
|
||||
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality"], 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: "droplet", values: &["--out"], bare: &[], run: droplet },
|
||||
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
|
||||
bare: &[],
|
||||
run: |a, _, _| mcp(a),
|
||||
},
|
||||
Subcommand {
|
||||
name: "serve",
|
||||
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
|
||||
bare: &[],
|
||||
run: |a, _, err| serve(a, err),
|
||||
},
|
||||
];
|
||||
|
||||
fn parse(args: &[String]) -> Result<Args, String> {
|
||||
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
|
||||
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
|
||||
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
|
||||
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let s = &args[i];
|
||||
while let Some(s) = args.get(i) {
|
||||
if s == "--help" || s == "-h" {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
|
||||
if sub.bare.contains(&k) {
|
||||
return Err(format!("{k} takes no value"));
|
||||
}
|
||||
if !sub.values.contains(&k) {
|
||||
return Err(unknown(k));
|
||||
}
|
||||
a.flags.push((k.to_owned(), Some(v.to_owned())));
|
||||
} else if VALUE_FLAGS.contains(&s.as_str()) {
|
||||
} else if sub.values.contains(&s.as_str()) {
|
||||
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
|
||||
a.flags.push((s.clone(), Some(v.clone())));
|
||||
i += 1;
|
||||
} else if s.starts_with("--") {
|
||||
} else if sub.bare.contains(&s.as_str()) {
|
||||
a.flags.push((s.clone(), None));
|
||||
} else if s.starts_with("--") {
|
||||
return Err(unknown(s));
|
||||
} else {
|
||||
a.positional.push(s.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(a)
|
||||
Ok(Some(a))
|
||||
}
|
||||
|
||||
impl Args {
|
||||
@@ -98,22 +130,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = write!(err, "{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
let parsed = match parse(&args[1..]) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let r = match cmd.as_str() {
|
||||
"convert" => convert(&parsed, out, err),
|
||||
"info" => info(&parsed, out),
|
||||
"run" => run_cmds(&parsed, out, err),
|
||||
"batch" => batch(&parsed, out, err),
|
||||
"commands" => commands(&parsed, out),
|
||||
"droplet" => droplet(&parsed, out, err),
|
||||
"mcp" => mcp(&parsed),
|
||||
"serve" => serve(&parsed, err),
|
||||
match cmd.as_str() {
|
||||
"-h" | "--help" | "help" => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
@@ -122,12 +139,25 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
|
||||
return 0;
|
||||
}
|
||||
other => {
|
||||
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
|
||||
_ => {}
|
||||
}
|
||||
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
|
||||
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
let parsed = match parse(sub, &args[1..]) {
|
||||
Ok(Some(p)) => p,
|
||||
// `<subcommand> --help`: usage, without reading inputs or starting a server.
|
||||
Ok(None) => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match r {
|
||||
match (sub.run)(&parsed, out, err) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}");
|
||||
@@ -137,10 +167,11 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
}
|
||||
|
||||
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
let mut o = ExportOptions::default();
|
||||
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
|
||||
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
|
||||
if let Some(q) = a.get("--quality") {
|
||||
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
|
||||
o.encode.jpeg_quality = q.clamp(1, 100);
|
||||
o.encode.jpeg_quality =
|
||||
q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
@@ -160,9 +191,10 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let [input, output] = a.positional.as_slice() else {
|
||||
return Err("convert needs <in> <out>".into());
|
||||
};
|
||||
let opts = export_opts(a)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
}
|
||||
@@ -207,6 +239,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let opts = export_opts(a)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
match (a.positional.as_slice(), a.get("--new")) {
|
||||
([file], None) => {
|
||||
@@ -216,7 +249,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
}
|
||||
([], Some(new)) => {
|
||||
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
|
||||
h.command_run("file.new", p).map_err(|e| e.to_string())?;
|
||||
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
|
||||
}
|
||||
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
|
||||
}
|
||||
@@ -229,29 +262,25 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
print_json(out, &json!({"command": id, "result": r}), true)?;
|
||||
}
|
||||
if let Some(o) = a.get("--out") {
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
|
||||
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
|
||||
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
|
||||
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
|
||||
/// 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}"))?;
|
||||
let list = match &v {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
|
||||
_ => return Err("actions JSON: expected an array".into()),
|
||||
};
|
||||
list.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
|
||||
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
|
||||
})
|
||||
.collect()
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
|
||||
/// trailing separator, through a symbolic link).
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
|
||||
}
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
@@ -266,16 +295,29 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
|
||||
let actions = parse_actions(&text)?;
|
||||
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).
|
||||
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
|
||||
return Err(format!(
|
||||
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
|
||||
out_dir.display()
|
||||
));
|
||||
}
|
||||
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
|
||||
let mut inputs: Vec<PathBuf> =
|
||||
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
|
||||
inputs.sort();
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
|
||||
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
|
||||
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
|
||||
for input in &inputs {
|
||||
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let target = out_dir.join(format!("{stem}.{ext}"));
|
||||
let target_text = target.to_string_lossy();
|
||||
let r = (|| -> Result<Vec<String>, String> {
|
||||
written.check(&target_text)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
h.open(input).map_err(|e| e.to_string())?;
|
||||
for (id, p) in &actions {
|
||||
@@ -287,6 +329,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
match r {
|
||||
Ok(ws) => {
|
||||
ok += 1;
|
||||
written.record(&target_text, &input.to_string_lossy());
|
||||
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,63 @@ fn usage_and_unknown_command() {
|
||||
assert!(out.starts_with("photocraft-cli "));
|
||||
}
|
||||
|
||||
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
|
||||
/// written, no server started), whatever else is on the line.
|
||||
#[test]
|
||||
fn every_subcommand_answers_help() {
|
||||
let d = tmp("help");
|
||||
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
|
||||
for help in ["--help", "-h"] {
|
||||
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
|
||||
}
|
||||
|
||||
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
|
||||
#[test]
|
||||
fn unknown_flags_are_usage_errors() {
|
||||
let d = tmp("flags");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let fails = |cmd: &mut Command, flag: &str| {
|
||||
let o = cmd.output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
|
||||
};
|
||||
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
|
||||
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
|
||||
assert!(!out_dir.exists(), "nothing written");
|
||||
}
|
||||
let x = d.join("x.png");
|
||||
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
|
||||
assert!(!x.exists());
|
||||
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
|
||||
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
|
||||
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
|
||||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
"80",
|
||||
]));
|
||||
assert!(out_dir.join("a.jpg").exists());
|
||||
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
|
||||
assert!(!out.trim().contains('\n'), "compact JSON is one line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_png_pcraft_png_is_lossless() {
|
||||
let d = tmp("convert");
|
||||
@@ -86,6 +143,37 @@ fn info_prints_layer_tree_json() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
|
||||
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
|
||||
#[test]
|
||||
fn info_and_convert_report_decode_warnings() {
|
||||
let d = tmp("decode-warnings");
|
||||
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
|
||||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
|
||||
// Three 1x1 frames.
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||||
for _ in 0..3 {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
];
|
||||
for (name, bytes, warning) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_commands_and_save() {
|
||||
let d = tmp("run");
|
||||
@@ -121,6 +209,15 @@ fn run_errors() {
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
// Params must be a JSON object, for `--new` and for each `--params`.
|
||||
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -155,6 +252,157 @@ fn parse_actions_formats() {
|
||||
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
|
||||
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
|
||||
assert!(photocraft_cli::parse_actions("7").is_err());
|
||||
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
|
||||
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
|
||||
for text in [
|
||||
r#"[["a",{"x":1}],["b"]]"#,
|
||||
r#"[["a",{"x":1}],"b"]"#,
|
||||
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
|
||||
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
|
||||
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
|
||||
] {
|
||||
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
|
||||
}
|
||||
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
|
||||
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
|
||||
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
|
||||
let file = d.join(format!("{name}.json"));
|
||||
std::fs::write(&file, actions).unwrap();
|
||||
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
|
||||
}
|
||||
|
||||
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
|
||||
/// as the object form; a malformed step fails before anything is written.
|
||||
#[test]
|
||||
fn batch_accepts_recorded_action_steps() {
|
||||
let d = tmp("batch-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let shapes = [
|
||||
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
|
||||
("pairs", r#"[["image.adjustments.invert",{}]]"#),
|
||||
("ids", r#"["image.adjustments.invert"]"#),
|
||||
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
|
||||
];
|
||||
for (name, actions) in shapes {
|
||||
let o = batch_with(&d, &input, name, actions, &[]);
|
||||
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
|
||||
for f in ["a.png", "b.png"] {
|
||||
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
|
||||
}
|
||||
}
|
||||
let o = batch_with(&d, &input, "bad", "[42]", &[]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(!d.join("bad").exists(), "nothing written");
|
||||
}
|
||||
|
||||
/// #490: a leading dot on `--format` doesn't double the dot in output names.
|
||||
#[test]
|
||||
fn batch_format_with_leading_dot() {
|
||||
let d = tmp("batch-dot");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
|
||||
let o = batch_with(&d, &input, name, invert, &["--format", format]);
|
||||
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
|
||||
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
|
||||
}
|
||||
}
|
||||
|
||||
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
|
||||
/// and nothing is written.
|
||||
#[test]
|
||||
fn quality_outside_1_to_100_is_refused() {
|
||||
let d = tmp("quality");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let a = input.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
|
||||
let out = d.join("q.jpg");
|
||||
let (mut convert, mut run) = (bin(), bin());
|
||||
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
|
||||
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
|
||||
for cmd in [&mut convert, &mut run] {
|
||||
let o = cmd.output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--quality {q}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
|
||||
assert!(!out.exists(), "--quality {q} wrote a file");
|
||||
}
|
||||
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
|
||||
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
|
||||
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
|
||||
}
|
||||
// The documented range still encodes, lower quality making a smaller file.
|
||||
let sizes: Vec<usize> = ["1", "50", "100"]
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let out = d.join(format!("q{q}.jpg"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
|
||||
std::fs::read(out).unwrap().len()
|
||||
})
|
||||
.collect();
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// #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]
|
||||
fn batch_refuses_to_write_over_its_inputs() {
|
||||
let d = tmp("batch-in-place");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let snapshot = || {
|
||||
let mut files: Vec<(PathBuf, Vec<u8>)> =
|
||||
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let before = snapshot();
|
||||
let batch = |out: &Path, extra: &[&str]| {
|
||||
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
|
||||
};
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
|
||||
let link = cfg!(unix).then(|| PathBuf::from("link"));
|
||||
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
|
||||
for extra in [&[][..], &["--format", "tif"][..]] {
|
||||
let o = batch(out, extra);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
|
||||
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
|
||||
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
|
||||
}
|
||||
}
|
||||
// A separate folder runs as before, leaving the originals alone.
|
||||
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
|
||||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||||
assert_eq!(snapshot(), before);
|
||||
// `--in-place` opts in: each result replaces its original.
|
||||
let o = batch(Path::new("in"), &["--in-place"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let after = snapshot();
|
||||
assert_eq!(after.len(), 2);
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -218,6 +466,38 @@ fn mcp_stdio_handshake_and_tool_call() {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
|
||||
#[test]
|
||||
fn batch_fails_inputs_that_share_an_output_name() {
|
||||
let d = tmp("batch-collide");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
// Same stem in other formats; `A.tif` differs only in case.
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
|
||||
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"a lost result must fail the run
|
||||
{out}
|
||||
{err}"
|
||||
);
|
||||
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
|
||||
assert_eq!(err.matches("not written").count(), 2, "{err}");
|
||||
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
|
||||
// Keeping each file's format, every input gets its own output.
|
||||
let out_dir = d.join("same");
|
||||
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
|
||||
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droplet_runs_an_action_on_files() {
|
||||
let d = tmp("droplet");
|
||||
@@ -237,3 +517,32 @@ fn droplet_runs_an_action_on_files() {
|
||||
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
|
||||
assert!(!o.status.success(), "not a droplet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
|
||||
let d = tmp("tiff-layers");
|
||||
let layer_count = |path: &std::path::Path| -> usize {
|
||||
let (info, _) = ok(bin().arg("info").arg(path));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
v["layers"].as_array().unwrap().len()
|
||||
};
|
||||
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
|
||||
let out = d.join(name);
|
||||
let mut cmd = bin();
|
||||
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
|
||||
if let Some(flag) = flag {
|
||||
cmd.arg(flag);
|
||||
}
|
||||
ok(cmd.arg("--out").arg(&out));
|
||||
assert_eq!(layer_count(&out), layers, "{name}");
|
||||
}
|
||||
// convert honours the same flag.
|
||||
let src = d.join("layered.tif");
|
||||
let flat = d.join("converted.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&flat));
|
||||
assert_eq!(layer_count(&flat), 1);
|
||||
let kept = d.join("kept.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
|
||||
assert_eq!(layer_count(&kept), 2);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
|
||||
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
|
||||
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
|
||||
//! changes nothing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn ok(cmd: &mut Command) -> String {
|
||||
let o = cmd.output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
|
||||
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
|
||||
out
|
||||
}
|
||||
|
||||
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
|
||||
fn exif(o: u16) -> Vec<u8> {
|
||||
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
|
||||
v.extend_from_slice(&o.to_le_bytes());
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
|
||||
v.extend_from_slice(b"PC1\0");
|
||||
v.extend_from_slice(&[0, 0, 0, 0]);
|
||||
v
|
||||
}
|
||||
|
||||
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
|
||||
fn upright_color(x: usize, y: usize) -> [u8; 3] {
|
||||
match (x < 8, y < 16) {
|
||||
(true, true) => [230, 20, 20],
|
||||
(false, true) => [20, 230, 20],
|
||||
_ => [20, 20, 230],
|
||||
}
|
||||
}
|
||||
|
||||
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
|
||||
fn phone_jpeg() -> Vec<u8> {
|
||||
let (w, h) = (16, 32);
|
||||
let mut px = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
|
||||
let (sx, sy) = (y, w - 1 - x);
|
||||
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
|
||||
}
|
||||
}
|
||||
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
|
||||
let jpeg =
|
||||
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
|
||||
let mut seg = b"Exif\0\0".to_vec();
|
||||
seg.extend_from_slice(&exif(6));
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
|
||||
fn stored(path: &Path) -> Image {
|
||||
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn assert_portrait_pixels(img: &Image) {
|
||||
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
|
||||
let i = (y * 16 + x) * 3;
|
||||
let got = &img.data()[i..i + 3];
|
||||
let want = upright_color(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let d = tmp("e2e");
|
||||
let jpg = d.join("IMG_0006.JPG");
|
||||
std::fs::write(&jpg, phone_jpeg()).unwrap();
|
||||
|
||||
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
|
||||
let png = d.join("upright.png");
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&png));
|
||||
let raw = stored(&png);
|
||||
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
|
||||
assert_portrait_pixels(&raw);
|
||||
if let Some(e) = raw.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
|
||||
}
|
||||
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
|
||||
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
|
||||
|
||||
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
|
||||
let turned = d.join("turned.png");
|
||||
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
|
||||
let t = stored(&turned);
|
||||
assert_eq!(t.dimensions(), (32, 16));
|
||||
if let Some(e) = t.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1);
|
||||
}
|
||||
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
|
||||
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
|
||||
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
|
||||
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
|
||||
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
|
||||
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
|
||||
|
||||
// .pcraft save, reopen and save again: identical document and pixels.
|
||||
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.
|
||||
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
|
||||
};
|
||||
let (ia, ib) = (info(&a), info(&b));
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
ok(bin().arg("convert").arg(&b).arg(&pb));
|
||||
let (xa, xb) = (stored(&pa), stored(&pb));
|
||||
assert_eq!(xa.dimensions(), (16, 32));
|
||||
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
|
||||
assert_eq!(xa.meta.exif, xb.meta.exif);
|
||||
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -6,6 +6,11 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -4,16 +4,27 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
|
||||
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
|
||||
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
|
||||
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
|
||||
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
|
||||
@keyframes photocraft-stalled { to { visibility: visible; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="photocraft_canvas"></canvas>
|
||||
<div id="photocraft_loading">Loading PhotoCraft…</div>
|
||||
<div id="photocraft_loading">
|
||||
<p>Loading PhotoCraft…</p>
|
||||
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,3 +28,18 @@ fn main() {
|
||||
fn main() {
|
||||
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
|
||||
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
|
||||
/// retry, without a hand-written script.
|
||||
#[test]
|
||||
fn loading_page_offers_a_reload_when_startup_stalls() {
|
||||
let html = include_str!("../index.html");
|
||||
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
|
||||
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
|
||||
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
|
||||
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"pcraft", "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",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
@@ -144,6 +144,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move || {
|
||||
|
||||
@@ -7,6 +7,11 @@ default-run = "photocraft"
|
||||
description = "Photocraft: an open-source, native image editor"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -24,21 +29,38 @@ photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
||||
open = "5"
|
||||
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
|
||||
ron = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
|
||||
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.4"
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = { version = "=0.13.2", default-features = false }
|
||||
|
||||
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
|
||||
# its own. Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Where the pointer is, read from the OS, so a file drop can tell the canvas (place as a layer)
|
||||
//! from the tab strip (open at that tab position) and the panels (open as a document), and the tab
|
||||
//! strip can show where a dragged file would go. winit 0.30's file drops carry no position, and
|
||||
//! during an OS drag the window gets no pointer events, so egui's last pointer is stale.
|
||||
//!
|
||||
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
|
||||
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
|
||||
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
|
||||
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
|
||||
//!
|
||||
//! No `unsafe`: every OS call goes through a safe binding.
|
||||
//!
|
||||
//! [`service`] builds the pointer-position service for the app's window, `None` where it can't
|
||||
//! be read.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEvent, NSWindow};
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
Some(Box::new(|ctx: &egui::Context| in_window_points().map(|(x, y)| egui::pos2(x as f32, y as f32) / ctx.zoom_factor())))
|
||||
}
|
||||
|
||||
/// The pointer in points from the top-left of the content view of our frontmost window under
|
||||
/// it (`None` when it's over none of ours, or off the main thread). Only our own windows are
|
||||
/// searched: a drop that reached us landed on one of them.
|
||||
fn in_window_points() -> Option<(f64, f64)> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let at = NSEvent::mouseLocation();
|
||||
let contains = |w: &NSWindow| {
|
||||
let f = w.frame();
|
||||
(f.origin.x..f.origin.x + f.size.width).contains(&at.x) && (f.origin.y..f.origin.y + f.size.height).contains(&at.y)
|
||||
};
|
||||
let window = NSApplication::sharedApplication(mtm).orderedWindows().iter().find(|w| w.isVisible() && contains(w))?;
|
||||
let view = window.contentView()?;
|
||||
let p = view.convertPoint_fromView(window.convertPointFromScreen(at), None);
|
||||
let y = if view.isFlipped() { p.y } else { view.bounds().size.height - p.y };
|
||||
Some((p.x, y))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
use x11rb::protocol::xproto::ConnectionExt;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
let window = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok()?,
|
||||
RawWindowHandle::Xcb(h) => h.window.get(),
|
||||
_ => return None,
|
||||
};
|
||||
// Opened on first use and kept: the pointer is read every frame while files hover.
|
||||
let mut conn: Option<RustConnection> = None;
|
||||
Some(Box::new(move |ctx: &egui::Context| match pointer_in(&mut conn, window) {
|
||||
Ok(p) => p.map(|(x, y)| egui::pos2(f32::from(x), f32::from(y)) / ctx.pixels_per_point()),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't read the pointer position: {e}");
|
||||
conn = None;
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// The pointer in pixels relative to `window` (`None` when it's on another screen).
|
||||
fn pointer_in(conn: &mut Option<RustConnection>, window: u32) -> Result<Option<(i16, i16)>, String> {
|
||||
let conn = match conn {
|
||||
Some(c) => c,
|
||||
None => conn.insert(RustConnection::connect(None).map_err(|e| e.to_string())?.0),
|
||||
};
|
||||
let reply = conn.query_pointer(window).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
Ok(reply.same_screen.then_some((reply.win_x, reply.win_y)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
Some(Box::new(|ctx: &egui::Context| {
|
||||
let pt = winsafe::GetCursorPos().map_err(|e| log::warn!("couldn't read the pointer position: {e}")).ok()?;
|
||||
let inner = ctx.input(|i| i.viewport().inner_rect)?;
|
||||
Some(super::client_points(egui::pos2(pt.x as f32, pt.y as f32), inner.min, ctx.pixels_per_point()))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen position in physical pixels to points in the window whose client area starts at
|
||||
/// `inner_min` (egui's `inner_rect.min`: the client origin in pixels over `ppp`). On Windows
|
||||
/// `GetCursorPos` and the client origin are both physical pixels (winit makes the process
|
||||
/// per-monitor DPI aware) and the window draws at one scale wherever it sits, so this holds across
|
||||
/// monitors with different DPIs.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn client_points(screen_px: egui::Pos2, inner_min: egui::Pos2, ppp: f32) -> egui::Pos2 {
|
||||
((screen_px - inner_min * ppp) / ppp).to_pos2()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::pos2;
|
||||
|
||||
#[test]
|
||||
fn client_points_on_a_second_monitor_at_another_scale() {
|
||||
// A 150% monitor left of a 100% primary: the window's client area starts at (-2400, 300) px.
|
||||
let inner_min = pos2(-2400.0, 300.0) / 1.5;
|
||||
assert_eq!(super::client_points(pos2(-2100.0, 450.0), inner_min, 1.5), pos2(200.0, 100.0));
|
||||
// The same window on the 100% primary.
|
||||
assert_eq!(super::client_points(pos2(500.0, 400.0), pos2(300.0, 300.0), 1.0), pos2(200.0, 100.0));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
use photocraft_engine::prefs::GpuBackend;
|
||||
use photocraft_engine::prefs::{GpuBackend, RenderingMode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The marker's file name in the config directory.
|
||||
@@ -154,6 +154,19 @@ pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_
|
||||
Plan { backend: pref, env: None, reason: None, remember: false }
|
||||
}
|
||||
|
||||
/// Resolve the rendering policy before the window exists. Explicit GPU/Automatic selections
|
||||
/// reset a legacy CPU backend to automatic; CPU keeps software-preferred window presentation.
|
||||
pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
let backend = match mode {
|
||||
RenderingMode::Cpu => GpuBackend::Cpu,
|
||||
RenderingMode::Auto | RenderingMode::Gpu if pref == GpuBackend::Cpu => GpuBackend::Auto,
|
||||
_ => pref,
|
||||
};
|
||||
let env = if safe_gpu || mode == RenderingMode::Cpu { None } else { env };
|
||||
let crashed = if mode == RenderingMode::Cpu { None } else { crashed };
|
||||
plan(backend, crashed, env, safe_gpu, os)
|
||||
}
|
||||
|
||||
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
|
||||
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
if plan.env.is_some() {
|
||||
@@ -173,6 +186,22 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
/// compile. Use a `dxcompiler.dll` shipped beside the executable, by its full path; otherwise FXC
|
||||
/// (`d3dcompiler_47.dll`, part of Windows). `WGPU_DX12_COMPILER` still picks one explicitly.
|
||||
pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Compiler {
|
||||
if let Some(c) = env.and_then(|v| v.trim().parse().ok()) {
|
||||
return c;
|
||||
}
|
||||
let beside_exe = exe_dir.map(|d| d.join("dxcompiler.dll")).filter(|p| p.is_file());
|
||||
match beside_exe.as_deref().and_then(Path::to_str) {
|
||||
Some(p) => wgpu::Dx12Compiler::DynamicDxc { dxc_path: p.to_string() },
|
||||
None => wgpu::Dx12Compiler::Fxc,
|
||||
}
|
||||
}
|
||||
|
||||
/// What adapter selection needs to know about an adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
@@ -211,8 +240,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
let any_ok = adapters.iter().any(|a| a.surface_ok);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok || !any_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
@@ -225,14 +253,19 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, 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).
|
||||
/// 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).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
if os == Os::Windows {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
@@ -250,6 +283,10 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
@@ -357,14 +394,15 @@ impl Sentinel {
|
||||
}
|
||||
}
|
||||
|
||||
/// `performance.gpuBackend` and `performance.useGpu` from the preferences file, read before the
|
||||
/// app (and its full preference load) exists. Unreadable values are the defaults.
|
||||
pub fn read_prefs(path: Option<&Path>) -> (GpuBackend, bool) {
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let perf = v.get("performance");
|
||||
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
|
||||
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
|
||||
(backend, use_gpu)
|
||||
let explicit = perf.and_then(|p| p.get("renderingMode")).and_then(Value::as_str).and_then(RenderingMode::parse);
|
||||
let mode = explicit.unwrap_or(if !use_gpu || backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto });
|
||||
(backend, mode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -376,6 +414,17 @@ mod tests {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_policy_ignores_hardware_backend_override() {
|
||||
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.backend, Cpu);
|
||||
assert!(p.env.is_none());
|
||||
assert_eq!(plan_with_mode(Vulkan, RenderingMode::Cpu, Some(&crashed("vulkan", "vulkan")), None, false, Os::Windows).backend, Cpu);
|
||||
let p = plan_with_mode(Auto, RenderingMode::Gpu, None, Some("vulkan"), true, Os::Windows);
|
||||
assert_eq!(p.backend, Cpu);
|
||||
assert!(p.env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_preference() {
|
||||
for os in [Os::Windows, Os::Mac, Os::Other] {
|
||||
@@ -474,6 +523,21 @@ mod tests {
|
||||
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_must_present_even_when_none_support_the_surface() {
|
||||
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
|
||||
a.surface_ok = false;
|
||||
assert_eq!(pick(&[a], Auto, Os::Windows), None);
|
||||
assert_eq!(pick(&[a], Cpu, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_policy_overrides_legacy_cpu_backend() {
|
||||
assert_eq!(plan_with_mode(Cpu, RenderingMode::Gpu, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan_with_mode(Metal, RenderingMode::Cpu, None, None, false, Os::Mac).backend, Cpu);
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Gpu, None, None, true, Os::Mac).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_round_trip_and_lenient_parse() {
|
||||
let m = Marker {
|
||||
@@ -513,7 +577,16 @@ mod tests {
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
// Another test in this binary spawns a process: between its fork and exec the child briefly
|
||||
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
|
||||
let (mut prev, mut s) = Sentinel::begin(&dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(&dir);
|
||||
}
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
@@ -542,14 +615,48 @@ mod tests {
|
||||
let dir = temp_dir("prefs");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("preferences.json");
|
||||
assert_eq!(read_prefs(None), (Auto, true));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Dx12, false));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
std::fs::write(&p, r#"{"performance": {"useGpu": false, "renderingMode": "gpu"}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Gpu));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #712: never a `dxcompiler.dll` found through the DLL search path (current directory, PATH).
|
||||
#[test]
|
||||
fn dx12_compiler_is_fxc_or_dxc_beside_the_exe() {
|
||||
use wgpu::Dx12Compiler as C;
|
||||
let dir = temp_dir("dxc");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(matches!(dx12_compiler(None, None), C::Fxc));
|
||||
assert!(matches!(dx12_compiler(None, Some(&dir)), C::Fxc));
|
||||
std::fs::write(dir.join("dxcompiler.dll"), b"MZ").unwrap();
|
||||
let expected = dir.join("dxcompiler.dll");
|
||||
assert!(matches!(dx12_compiler(None, Some(&dir)), C::DynamicDxc { dxc_path } if Path::new(&dxc_path) == expected));
|
||||
// An explicit WGPU_DX12_COMPILER wins; an unknown value is ignored.
|
||||
assert!(matches!(dx12_compiler(Some(" FXC "), Some(&dir)), C::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("dxc"), None), C::DynamicDxc { dxc_path } if dxc_path == "dxcompiler.dll"));
|
||||
assert!(matches!(dx12_compiler(Some("quantum"), None), C::Fxc));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
C::Fxc => true,
|
||||
C::DynamicDxc { dxc_path } => Path::new(dxc_path).is_absolute(),
|
||||
C::StaticDxc | C::Auto => false,
|
||||
};
|
||||
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! macOS title bar dragging. The window draws under a transparent title bar (`main.rs`), so AppKit
|
||||
//! would otherwise drag the window from anywhere in that strip, menus included: pressing a menu
|
||||
//! title and dragging down to an item moved the window instead. Turning AppKit's own dragging off
|
||||
//! leaves only the app's drag region (the free gap between the menus and the controls,
|
||||
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
|
||||
//! `performWindowDragWithEvent:`, which works on an unmovable window). All safe objc2 calls.
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
|
||||
/// Stop AppKit dragging the app's windows by their title bar strip.
|
||||
pub fn disable_native_title_drag() {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
log::warn!("title bar: not on the main thread; AppKit window dragging left on");
|
||||
return;
|
||||
};
|
||||
for window in NSApplication::sharedApplication(mtm).windows().iter() {
|
||||
window.setMovable(false);
|
||||
}
|
||||
}
|
||||
+235
-26
@@ -23,7 +23,10 @@ mod app_icon;
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
@@ -32,6 +35,7 @@ mod services;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
@@ -39,9 +43,82 @@ use photocraft_ui_egui::PhotocraftApp;
|
||||
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
|
||||
const APP_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
/// different offset every launch (#419). Wayland compositors place windows themselves.
|
||||
fn native_options() -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
centered: true,
|
||||
// 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")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
|
||||
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
|
||||
}
|
||||
|
||||
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
|
||||
/// when there are none, else 2 (a command-line usage error), never 0.
|
||||
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
|
||||
if errors.is_empty() { None } else { Some(2) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_port_tests {
|
||||
use super::{control_args_exit_code, parse_control_port};
|
||||
|
||||
#[test]
|
||||
fn control_port_errors_exit_non_zero() {
|
||||
assert_eq!(control_args_exit_code(&[]), None);
|
||||
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
|
||||
assert_eq!(control_args_exit_code(&errors), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_accepts_valid_numbers() {
|
||||
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
|
||||
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
|
||||
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_rejects_unparseable_values_naming_them() {
|
||||
// Out of range, a following flag eaten as the value, and an empty value all fail,
|
||||
// naming the offending value in the message.
|
||||
let err = parse_control_port("78787", "--control").unwrap_err();
|
||||
assert!(err.contains("78787"), "{err}");
|
||||
assert!(parse_control_port("--safe-gpu", "--control").is_err());
|
||||
assert!(parse_control_port("", "--control").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
crash_guard::install_hook();
|
||||
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
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()) {
|
||||
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
}
|
||||
}
|
||||
let mut control_token = None;
|
||||
let mut control_token_file = None;
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
@@ -51,7 +128,13 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control" => match args.next() {
|
||||
Some(value) => match parse_control_port(&value, "--control") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
},
|
||||
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
"--control-token" => control_token = args.next(),
|
||||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
@@ -67,6 +150,16 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed control port must not silently drop the control server (issue #701): name the
|
||||
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
|
||||
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
|
||||
if let Some(code) = control_args_exit_code(&control_arg_errors) {
|
||||
for error in &control_arg_errors {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -116,34 +209,25 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _tablet = tablet::install_macos(&stylus_feed);
|
||||
|
||||
// Read the main display's ICC profile while the window opens (colour-managed canvas).
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
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);
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
let mut options = native_options();
|
||||
// eframe restores the saved window layout before our code runs; drop values that would crash it.
|
||||
ui_state::sanitize(options.persistence_path.as_deref());
|
||||
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
|
||||
// driver moves to a safer one), and lock this start's marker until the first frames render.
|
||||
let t_sentinel = std::time::Instant::now();
|
||||
let os = gpu_startup::Os::current();
|
||||
let (pref, _) = gpu_startup::read_prefs(services::prefs_file().as_deref());
|
||||
let (pref, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
|
||||
let (previous, sentinel) = match services::config_dir() {
|
||||
Some(dir) => gpu_startup::Sentinel::begin(&dir),
|
||||
None => (gpu_startup::Previous::Clean, None),
|
||||
};
|
||||
let env_backend = std::env::var("WGPU_BACKEND").ok();
|
||||
let plan = gpu_startup::plan(pref, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
let plan = gpu_startup::plan_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
if let Some(m) = previous.crashed() {
|
||||
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
|
||||
}
|
||||
@@ -163,31 +247,64 @@ fn main() -> eframe::Result {
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_callback = app_created.clone();
|
||||
let started_sentinel = sentinel.clone();
|
||||
let result = eframe::run_native(
|
||||
"Photocraft",
|
||||
"PhotoCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_callback.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
|
||||
let mut services = services::native(automation);
|
||||
services.preset_store = presets;
|
||||
#[cfg(target_os = "linux")]
|
||||
let display = tablet::DisplayKind::of(cc);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_window::disable_native_title_drag();
|
||||
// Long commands and file opens run as background jobs with progress and Cancel (#210).
|
||||
app.background_jobs = std::env::var_os("PHOTOCRAFT_INLINE_JOBS").is_none();
|
||||
// Displays and their profiles (#569): wait briefly so the first frames already use
|
||||
// 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));
|
||||
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),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
photocraft_ui_egui::monitor_status::apply(&mut app, Err("the display profile reader stopped without an answer".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = plan.reason.clone().or_else(|| gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone());
|
||||
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
|
||||
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
|
||||
(reason, note) => reason.or(note),
|
||||
};
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
if plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
@@ -195,13 +312,33 @@ fn main() -> eframe::Result {
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
}
|
||||
}
|
||||
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
|
||||
(app.perf.gpu_info.canvas == "cpu" && mode != photocraft_engine::prefs::RenderingMode::Cpu)
|
||||
.then(|| app.perf.gpu_info.fallback.clone())
|
||||
.flatten()
|
||||
});
|
||||
if let Some(reason) = fallback_reason {
|
||||
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
let remember_cpu =
|
||||
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
|
||||
let remember = plan.remember.then_some(plan.backend);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if remember_cpu
|
||||
&& let Err(error) = app.run(
|
||||
"prefs.set",
|
||||
serde_json::json!({"values": {
|
||||
"performance.renderingMode": "cpu", "performance.useGpu": false
|
||||
}}),
|
||||
)
|
||||
{
|
||||
log::warn!("couldn't remember CPU recovery: {error}");
|
||||
}
|
||||
if let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
@@ -217,16 +354,18 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above
|
||||
// and the X11 reader write into this feed.
|
||||
app.stylus.feed = stylus_feed;
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
// Portable marker found but its data folder isn't writable (#228): say where settings went.
|
||||
if let Some(w) = &app_dirs::current().warning {
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false);
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
@@ -238,5 +377,75 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
s.finish();
|
||||
}
|
||||
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
|
||||
// Only renderer initialization failures qualify; never restart after editing has begun.
|
||||
if retry_cpu && !app_created.load(std::sync::atomic::Ordering::Relaxed) && matches!(&result, Err(eframe::Error::Wgpu(_))) {
|
||||
let reason = result.as_ref().err().map(ToString::to_string).unwrap_or_default();
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let launched = std::process::Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.arg("--safe-gpu")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
|
||||
.spawn();
|
||||
match launched {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => log::error!("could not start CPU compatibility mode: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn window_and_panel_geometry_survive_a_restart() {
|
||||
let options = super::native_options();
|
||||
assert!(options.persist_window);
|
||||
assert_eq!(options.persistence_path, super::services::config_dir().map(|dir| dir.join("ui.ron")));
|
||||
|
||||
#[derive(Default)]
|
||||
struct Storage(std::collections::BTreeMap<String, String>);
|
||||
impl eframe::Storage for Storage {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.into(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
|
||||
let mut output = ctx.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mut storage = Storage::default();
|
||||
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
|
||||
let restored = egui::Context::default();
|
||||
restored.memory_mut(|memory| *memory = eframe::get_value(&storage, "egui").unwrap());
|
||||
let mut output = restored.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
|
||||
assert_eq!(panel.size().x, 410.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_opens_centred_at_its_default_size() {
|
||||
let o = super::native_options();
|
||||
assert!(o.centered, "#419: centred, not cascaded from the top-left corner");
|
||||
assert_eq!(o.viewport.inner_size, Some(egui::vec2(1440.0, 900.0)));
|
||||
// eframe shrinks the start size to the monitor, so the centred position is on-screen.
|
||||
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,145 @@
|
||||
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`).
|
||||
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`; #569).
|
||||
//!
|
||||
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
|
||||
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
|
||||
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
|
||||
//! return `None` (sRGB, or the profile chosen in Color Settings).
|
||||
//! macOS: every `NSScreen` with its `CGDirectDisplayID`, name, frame and
|
||||
//! `colorSpace.ICCProfileData`, documented AppKit APIs read through `osascript` (AppKit via
|
||||
//! 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).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// Starts reading the main display's profile in the background.
|
||||
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
|
||||
use photocraft_engine::display_color::Display;
|
||||
|
||||
/// What the platform reader returns: the displays, or why there are none.
|
||||
pub type Detection = Result<Vec<Display>, String>;
|
||||
|
||||
/// Starts reading the displays in the background; `None` when the platform has no reader.
|
||||
pub fn detect_async() -> Option<Receiver<Detection>> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return None;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
});
|
||||
rx
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
/// points, Cocoa's bottom-left origin), the profile as base64, the profile's name in System
|
||||
/// Settings and the display's name, tab-separated. Numbers as integers (text conversion of
|
||||
/// reals follows the user's locale, e.g. "0,0").
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
let out = std::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"use framework \"AppKit\"",
|
||||
"-e",
|
||||
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
|
||||
])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
const SCRIPT: &str = r#"use framework "AppKit"
|
||||
set out to ""
|
||||
repeat with s in (current application's NSScreen's screens() as list)
|
||||
set f to s's frame()
|
||||
set n to ((s's deviceDescription()'s objectForKey:"NSScreenNumber") as integer)
|
||||
set icc to ""
|
||||
set cs to s's colorSpace()
|
||||
set csName to ""
|
||||
if cs is not missing value then
|
||||
set csName to ((cs's localizedName()) as text)
|
||||
set d to cs's ICCProfileData()
|
||||
if d is not missing value then set icc to ((d's base64EncodedStringWithOptions:0) as text)
|
||||
end if
|
||||
set out to out & n & tab & ((item 1 of item 1 of f) as integer) & tab & ((item 2 of item 1 of f) as integer) & tab & ((item 1 of item 2 of f) as integer) & tab & ((item 2 of item 2 of f) as integer) & tab & icc & tab & csName & tab & ((s's localizedName()) as text) & linefeed
|
||||
end repeat
|
||||
return out"#;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect() -> Detection {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/osascript");
|
||||
cmd.args(["-e", SCRIPT]);
|
||||
parse_reply(&run_helper(&mut cmd, HELPER_TIMEOUT)?)
|
||||
}
|
||||
|
||||
/// How long a reading may take before the helper is stopped (it normally takes about 0.4 s).
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const HELPER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Run the helper and return its standard output; a helper that fails, or runs longer than
|
||||
/// `timeout`, is an error (and is killed and reaped), so a hung reading can't block later ones.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
let mut child =
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run the display profile reader: {e}"))?;
|
||||
// Drain both pipes while waiting: a reply larger than the pipe buffer would otherwise block
|
||||
// the helper until the deadline.
|
||||
let drain = |p: Option<Box<dyn Read + Send>>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut p) = p {
|
||||
let _ = p.read_to_end(&mut buf);
|
||||
}
|
||||
buf
|
||||
})
|
||||
};
|
||||
let out = drain(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let err = drain(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("reading the display profiles took longer than {} s and was stopped", timeout.as_secs()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("waiting for the display profile reader failed: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let out = out.join().unwrap_or_default();
|
||||
let err = err.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
let err = String::from_utf8_lossy(&err);
|
||||
let first = err.lines().next().unwrap_or("").trim();
|
||||
return Err(format!("reading the display profiles failed ({status}){}", if first.is_empty() { String::new() } else { format!(": {first}") }));
|
||||
}
|
||||
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36.
|
||||
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
None
|
||||
fn detect() -> Detection {
|
||||
Err("this platform doesn't report display profiles".into())
|
||||
}
|
||||
|
||||
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
|
||||
/// (the coordinates window positions use). Lines that don't parse are skipped.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn parse_reply(out: &str) -> Detection {
|
||||
let mut displays = Vec::new();
|
||||
let mut primary_height = None;
|
||||
for line in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let f: Vec<&str> = line.splitn(8, '\t').collect();
|
||||
let [id, x, y, w, h, b64, profile_name, name] = f.as_slice() else { continue };
|
||||
let num = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
let (Some(id), Some(x), Some(y), Some(w), Some(h)) = (id.trim().parse::<u32>().ok(), num(x), num(y), num(w), num(h)) else { continue };
|
||||
// Cocoa frames are relative to the primary screen's bottom-left corner, y up.
|
||||
let top = *primary_height.get_or_insert(h) - (y + h);
|
||||
let name = name.trim();
|
||||
let name = if name.is_empty() { format!("Display {id}") } else { name.to_string() };
|
||||
let b64 = b64.trim();
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36; anything else is
|
||||
// treated as no profile (the engine then says the display has none).
|
||||
let icc = (!b64.is_empty()).then(|| base64_decode(b64)).flatten().filter(|b| b.len() >= 132 && b.get(36..40) == Some(b"acsp"));
|
||||
let profile_name = Some(profile_name.trim().to_string()).filter(|n| !n.is_empty());
|
||||
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
|
||||
}
|
||||
if displays.is_empty() {
|
||||
let first = out.lines().next().unwrap_or("").trim();
|
||||
return Err(if first.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {first}") });
|
||||
}
|
||||
Ok(displays)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
|
||||
@@ -87,12 +187,73 @@ mod tests {
|
||||
assert!(base64_decode("a$==").is_none());
|
||||
}
|
||||
|
||||
/// The detected profile (when there is one) parses as an RGB profile.
|
||||
#[test]
|
||||
fn detected_profile_parses() {
|
||||
if let Some(bytes) = detect() {
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
|
||||
fn helper_replies() {
|
||||
// Only the ICC signature is checked here; parsing is the engine's job.
|
||||
let mut icc = vec![0u8; 132];
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
let b64 = base64_encode(&icc);
|
||||
// The #569 setup: built-in display primary, the external one right of it and higher up.
|
||||
let reply = format!(
|
||||
"1\t0\t0\t1728\t1117\t{b64}\tColor LCD\tBuilt-in Retina Display\n4\t1728\t92\t3008\t1692\t{b64}\tApple_Display_26-04-13.icc\tROG PG32UQX\n"
|
||||
);
|
||||
let d = parse_reply(&reply).unwrap();
|
||||
assert_eq!(d.len(), 2);
|
||||
assert_eq!((d[0].id, d[0].name.as_str(), d[0].frame), (1, "Built-in Retina Display", [0.0, 0.0, 1728.0, 1117.0]));
|
||||
assert_eq!((d[1].id, d[1].frame), (4, [1728.0, -667.0, 3008.0, 1692.0]));
|
||||
assert_eq!(d[1].profile_name.as_deref(), Some("Apple_Display_26-04-13.icc"));
|
||||
assert_eq!(d[1].icc.as_deref(), Some(&icc));
|
||||
// No profile, a bad profile, no name: kept, without a profile or with a fallback name.
|
||||
let d = parse_reply("2\t0\t0\t800\t600\t\t\t\n3\t800\t0\t800\t600\taGVsbG8=\tX\tY\n").unwrap();
|
||||
assert_eq!((d[0].name.as_str(), d[0].icc.is_none(), d[0].profile_name.is_none()), ("Display 2", true, true));
|
||||
assert!(d[1].icc.is_none());
|
||||
// Garbage lines are skipped; nothing usable is an error.
|
||||
assert_eq!(parse_reply("junk\n5\t0\t0\t10\t10\t\t\tZ").unwrap().len(), 1);
|
||||
assert!(parse_reply("").is_err());
|
||||
assert!(parse_reply("execution error: -1728").unwrap_err().contains("-1728"));
|
||||
}
|
||||
|
||||
// Drives /bin/sleep and /bin/sh; the helper itself only runs on macOS.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = run_helper(std::process::Command::new("/bin/sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
|
||||
assert!(e.contains("took longer than"), "{e}");
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "killed at the deadline");
|
||||
// 1 MB on stdout (far beyond a pipe buffer) arrives whole.
|
||||
let big = run_helper(std::process::Command::new("/bin/sh").args(["-c", "head -c 1048576 /dev/zero | tr '\\0' a"]), std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
assert_eq!(big.len(), 1 << 20);
|
||||
let e = run_helper(std::process::Command::new("/bin/sh").args(["-c", "echo boom >&2; exit 3"]), std::time::Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("boom"), "{e}");
|
||||
assert!(run_helper(&mut std::process::Command::new("/nonexistent/helper"), std::time::Duration::from_secs(1)).is_err());
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = c.iter().enumerate().fold(0u32, |n, (i, v)| n | u32::from(*v) << (16 - 8 * i));
|
||||
for i in 0..=c.len() {
|
||||
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
while !s.len().is_multiple_of(4) {
|
||||
s.push('=');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
|
||||
#[test]
|
||||
fn detected_displays_parse() {
|
||||
if let Ok(displays) = detect() {
|
||||
for d in displays {
|
||||
let Some(icc) = d.icc else { continue };
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&icc).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}: {}", d.name, p.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
-35
@@ -3,11 +3,10 @@
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_format::Autosaver;
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::Services;
|
||||
use photocraft_ui_egui::{Recovered, Services};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -15,15 +14,23 @@ use std::sync::Arc;
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"pcraft", "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",
|
||||
];
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
|
||||
/// keeps defaulting to Photoshop.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] =
|
||||
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Photoshop", &["psd", "psb"]),
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
];
|
||||
|
||||
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
|
||||
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
@@ -64,9 +71,63 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
|
||||
|
||||
/// Run `f` on the recovery store (`Err` without a config directory). The service closures never
|
||||
/// call each other, so the store is never borrowed twice.
|
||||
fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R) -> Result<R, String> {
|
||||
let mut slot = store.try_borrow_mut().map_err(|_| "crash recovery is busy".to_string())?;
|
||||
Ok(f(slot.as_mut().ok_or("no config directory")?))
|
||||
}
|
||||
|
||||
/// Crash recovery: background incremental .pcraft autosaves into `dir` (`None`: no config
|
||||
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
|
||||
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
|
||||
fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
|
||||
let (s1, s2, s3) = (store.clone(), store.clone(), store.clone());
|
||||
Services {
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
with_store(&s1, |s| s.autosave(doc, revision, path.map(str::to_string)))
|
||||
})),
|
||||
discard_autosave: Some(Box::new(move |id: u64| {
|
||||
let _ = with_store(&s2, |s| s.discard(id));
|
||||
})),
|
||||
recover: Some(Box::new(move || {
|
||||
let found = with_store(&s3, |s| s.recover()).unwrap_or_default();
|
||||
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
|
||||
})),
|
||||
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
|
||||
let _ = with_store(&store, |s| s.adopt(id, key));
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
|
||||
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
|
||||
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
|
||||
fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
paths.iter().find_map(|path| {
|
||||
// text/uri-list lines end in CRLF (RFC 2483, and GTK writes them so), but arboard splits
|
||||
// on LF only, so a path copied in GNOME Files arrives with a trailing '\r'.
|
||||
let path = path.to_str().map_or_else(|| path.clone(), |s| PathBuf::from(s.trim_end_matches('\r')));
|
||||
let mut head = Vec::with_capacity(256);
|
||||
std::fs::File::open(&path).ok()?.take(256).read_to_end(&mut head).ok()?;
|
||||
// TGA has no magic number and is only guessed from the header, so trust the extension
|
||||
// too before reading what may be a large non-image file.
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
match photocraft_codecs::detect(&head)? {
|
||||
photocraft_codecs::Format::Tga if photocraft_codecs::from_extension(ext) != Some(photocraft_codecs::Format::Tga) => return None,
|
||||
_ => {}
|
||||
}
|
||||
let img = photocraft_codecs::decode(&std::fs::read(&path).ok()?).ok()?;
|
||||
Some((img.width(), img.height(), img.to_rgba8()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
|
||||
let savers2 = savers.clone();
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let automation_read = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
@@ -78,6 +139,8 @@ 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 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(|_| {
|
||||
Box::new(|id: &str, params: &serde_json::Value| {
|
||||
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
|
||||
@@ -92,13 +155,23 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
// A read failure goes back to the app, which reports it like any other open failure.
|
||||
let bytes = photocraft_format::read_file(&path).map_err(|e| e.to_string());
|
||||
Some((path.to_string_lossy().to_string(), bytes))
|
||||
})),
|
||||
pick_open_paths: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("All Formats", OPEN_EXTS)
|
||||
.add_filter("PhotoCraft", &["pcraft"])
|
||||
.pick_files()
|
||||
.map(|paths| paths.into_iter().map(|path| path.to_string_lossy().into_owned()).collect())
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let p = std::path::Path::new(suggested);
|
||||
let mut d = rfd::FileDialog::new();
|
||||
@@ -114,6 +187,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_command,
|
||||
automation_authorize,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
@@ -139,37 +213,17 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().ok()?),
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})
|
||||
}),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
// Crash recovery: background incremental .pcraft saves into the recovery directory.
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
let dir = recovery_dir().ok_or("no config directory")?;
|
||||
let mut map = savers.borrow_mut();
|
||||
let saver = map.entry(doc.id.0).or_insert_with(|| Autosaver::new(&dir, &format!("doc-{}", doc.id.0)));
|
||||
saver.request(doc.clone(), revision, path.map(str::to_string), Default::default());
|
||||
Ok(())
|
||||
})),
|
||||
discard_autosave: Some(Box::new(move |id: u64| {
|
||||
if let Some(s) = savers2.borrow_mut().remove(&id) {
|
||||
let _ = s.discard();
|
||||
}
|
||||
})),
|
||||
recover: Some(Box::new(|| {
|
||||
let Some(dir) = recovery_dir() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for entry in photocraft_format::list_recovery(&dir) {
|
||||
if let Ok(doc) = photocraft_format::recover(&entry) {
|
||||
out.push((entry.info.original_path.clone(), doc));
|
||||
}
|
||||
// Recovered documents autosave again under their new ids.
|
||||
let _ = photocraft_format::discard_recovery(&dir, &entry);
|
||||
}
|
||||
out
|
||||
})),
|
||||
append_text: Some(Box::new(|path: &str, text: &str| {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
|
||||
@@ -179,6 +233,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
os_events: None,
|
||||
// Set by main, which starts loading the store before the window opens.
|
||||
preset_store: None,
|
||||
is_wayland: false,
|
||||
..recovery_services(recovery_dir())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,3 +299,174 @@ pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
|
||||
};
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_format::list_recovery;
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::json;
|
||||
|
||||
/// "Export As" formats must lead with their own filter, or the save panel appends the first one's extension (`photo.webp.psd`).
|
||||
#[test]
|
||||
fn save_filters_lead_with_every_export_format() {
|
||||
for ext in ["png", "jpg", "webp", "tif", "tga"] {
|
||||
assert!(save_filters(&format!("photo.{ext}"))[0].1.contains(&ext), "{ext}");
|
||||
}
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
|
||||
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
|
||||
/// already queued finish, nothing else runs).
|
||||
fn launch(dir: &Path) -> PhotocraftApp {
|
||||
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
|
||||
}
|
||||
|
||||
/// The first pixel of each open document, and whether it's unsaved.
|
||||
fn open_docs(app: &PhotocraftApp) -> Vec<([f32; 4], bool)> {
|
||||
app.session.documents().iter().map(|d| (photocraft_compose::flatten(&d.doc).px.first().copied().unwrap_or_default(), d.is_dirty())).collect()
|
||||
}
|
||||
|
||||
fn index_of(app: &PhotocraftApp, color: [f32; 4]) -> usize {
|
||||
open_docs(app).iter().position(|(c, _)| *c == color).unwrap()
|
||||
}
|
||||
|
||||
fn new_doc(app: &mut PhotocraftApp, color: &str) {
|
||||
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
app.run("edit.fill", json!({"color": color})).unwrap();
|
||||
}
|
||||
|
||||
fn autosave(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
prefs_ui::autosave_now(app);
|
||||
prefs_ui::tick(app, ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_documents_survive_a_second_crash_until_saved_or_closed() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-recovery-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ctx = egui::Context::default();
|
||||
// Launch 1: two unsaved documents are autosaved, then the app crashes.
|
||||
{
|
||||
let mut app = launch(&dir);
|
||||
new_doc(&mut app, "#ff0000");
|
||||
new_doc(&mut app, "#0000ff");
|
||||
autosave(&mut app, &ctx);
|
||||
}
|
||||
assert_eq!(list_recovery(&dir).len(), 2);
|
||||
// Launch 2 recovers both and crashes again before its first autosave (#444).
|
||||
{
|
||||
let mut app = launch(&dir);
|
||||
let mut docs = open_docs(&app);
|
||||
docs.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]));
|
||||
assert_eq!(docs, [(BLUE, true), (RED, true)], "recovered and still unsaved");
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
}
|
||||
assert_eq!(list_recovery(&dir).len(), 2, "recovering keeps the entries on disk");
|
||||
// Launch 3 has both again; autosaving unchanged documents adds nothing, a new document
|
||||
// (whose id may repeat one from an earlier launch) gets an entry of its own.
|
||||
{
|
||||
let mut app = launch(&dir);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
autosave(&mut app, &ctx);
|
||||
new_doc(&mut app, "#00ff00");
|
||||
autosave(&mut app, &ctx);
|
||||
}
|
||||
assert_eq!(list_recovery(&dir).len(), 3);
|
||||
// Launch 4: closing and saving recovered documents removes their entries, no duplicates.
|
||||
let mut app = launch(&dir);
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
let red = index_of(&app, RED);
|
||||
app.run("file.close", json!({"document": red})).unwrap();
|
||||
// Saving marks the revision saved (the file write itself is the save service's job).
|
||||
assert!(app.session.set_active(index_of(&app, BLUE)));
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
let left = list_recovery(&dir);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(photocraft_compose::flatten(&photocraft_format::recover(&left[0]).unwrap()).px.first().copied(), Some(GREEN));
|
||||
let green = index_of(&app, GREEN);
|
||||
app.run("file.close", json!({"document": green})).unwrap();
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
assert!(list_recovery(&dir).is_empty());
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-services-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn red_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// A 1×1 uncompressed 32-bit TGA (TGA has no magic number).
|
||||
fn tga_1x1() -> Vec<u8> {
|
||||
let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8];
|
||||
b.extend_from_slice(&[0, 0, 255, 255]); // BGRA red
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_image_file_pastes_its_pixels() {
|
||||
let dir = temp("png");
|
||||
let png = dir.join("red.png");
|
||||
std::fs::write(&png, red_png(3, 2)).unwrap();
|
||||
let (w, h, px) = image_from_files(&[png]).unwrap();
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(px, [255u8, 0, 0, 255].repeat(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_from_a_uri_list_is_ignored() {
|
||||
// arboard splits text/uri-list on LF, so GNOME Files' CRLF list leaves a '\r' behind.
|
||||
let dir = temp("crlf");
|
||||
let png = dir.join("red image.png");
|
||||
std::fs::write(&png, red_png(2, 2)).unwrap();
|
||||
let with_cr = PathBuf::from(format!("{}\r", png.display()));
|
||||
assert_eq!(image_from_files(&[with_cr]).map(|(w, h, _)| (w, h)), Some((2, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_images_are_skipped_for_the_first_image() {
|
||||
let dir = temp("mixed");
|
||||
let (txt, png) = (dir.join("notes.txt"), dir.join("red.png"));
|
||||
std::fs::write(&txt, b"not an image").unwrap();
|
||||
std::fs::write(&png, red_png(4, 1)).unwrap();
|
||||
let missing = dir.join("gone.png");
|
||||
assert_eq!(image_from_files(&[missing.clone(), txt.clone(), dir.clone(), png]).map(|(w, h, _)| (w, h)), Some((4, 1)));
|
||||
assert!(image_from_files(&[missing, txt, dir]).is_none());
|
||||
assert!(image_from_files(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_image_is_skipped_not_a_panic() {
|
||||
let dir = temp("damaged");
|
||||
let bad = dir.join("cut.png");
|
||||
std::fs::write(&bad, &red_png(8, 8)[..20]).unwrap();
|
||||
assert!(image_from_files(&[bad]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_is_trusted_only_with_its_extension() {
|
||||
let dir = temp("tga");
|
||||
let (tga, bin) = (dir.join("red.tga"), dir.join("red.bin"));
|
||||
std::fs::write(&tga, tga_1x1()).unwrap();
|
||||
std::fs::write(&bin, tga_1x1()).unwrap();
|
||||
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_files(&[bin]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The saved window geometry and egui layout (`ui.ron`, written by eframe's persistence).
|
||||
//!
|
||||
//! eframe restores this state while it creates the window, before PhotoCraft's code runs, and
|
||||
//! some values it does not check abort startup: a zoom factor of zero, NaN or 1e30 fails a
|
||||
//! `dpi` assertion, and a NaN or huge window size fails surface creation. A truncated or
|
||||
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
|
||||
//! holding such values (the window and panels open at their defaults; nothing else is lost).
|
||||
//! A file that isn't RON at all is left alone: eframe already ignores it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
|
||||
const MAX_COORD: f64 = 1.0e6;
|
||||
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
|
||||
/// 300% on a 1× display is 3).
|
||||
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
|
||||
|
||||
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
|
||||
pub fn sanitize(path: Option<&Path>) {
|
||||
let Some(path) = path else { return };
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
if let Err(why) = check(&text) {
|
||||
log::warn!("ignoring saved window layout {}: {why}", path.display());
|
||||
if let Err(e) = std::fs::remove_file(path) {
|
||||
log::warn!("could not remove {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Err` names the first value that would crash startup.
|
||||
fn check(text: &str) -> Result<(), String> {
|
||||
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
|
||||
for (key, value) in &kv {
|
||||
// eframe also skips an entry that doesn't parse.
|
||||
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
|
||||
floats_sane(&v, 0).map_err(|e| format!("{key}: {e}"))?;
|
||||
let zoom = (key == "egui").then(|| field(&v, "options").and_then(|o| field(o, "zoom_factor")).and_then(as_f64)).flatten();
|
||||
if let Some(z) = zoom.filter(|z| !ZOOM.contains(z)) {
|
||||
return Err(format!("egui: zoom factor {z}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
|
||||
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
|
||||
if depth > 64 {
|
||||
return Err("nested too deeply".into());
|
||||
}
|
||||
match v {
|
||||
ron::Value::Number(_) => match as_f64(v) {
|
||||
Some(f) if !f.is_finite() || f.abs() > MAX_COORD => Err(format!("value {f}")),
|
||||
_ => Ok(()),
|
||||
},
|
||||
ron::Value::Option(Some(inner)) => floats_sane(inner, depth + 1),
|
||||
ron::Value::Seq(items) => items.iter().try_for_each(|i| floats_sane(i, depth + 1)),
|
||||
ron::Value::Map(m) => m.iter().try_for_each(|(k, v)| floats_sane(k, depth + 1).and_then(|()| floats_sane(v, depth + 1))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A float (integers such as egui's hashed ids are not coordinates).
|
||||
fn as_f64(v: &ron::Value) -> Option<f64> {
|
||||
match v {
|
||||
ron::Value::Number(ron::value::Number::F32(f)) => Some(f64::from(f.get())),
|
||||
ron::Value::Number(ron::value::Number::F64(f)) => Some(f.get()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
match v {
|
||||
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::check;
|
||||
|
||||
fn file(window: &str, zoom: &str) -> String {
|
||||
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
|
||||
format!("{{\"window\":{window:?},\"egui\":{egui:?}}}")
|
||||
}
|
||||
|
||||
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
|
||||
|
||||
#[test]
|
||||
fn a_saved_layout_is_kept() {
|
||||
assert_eq!(check(&file(WINDOW, "1.0")), Ok(()));
|
||||
assert_eq!(check(&file(WINDOW, "0.375")), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_abort_startup_are_rejected() {
|
||||
// Each of these aborted the app at launch before the check (dpi / wgpu assertions).
|
||||
for zoom in ["0.0", "NaN", "1e30", "-1.0"] {
|
||||
assert!(check(&file(WINDOW, zoom)).is_err(), "zoom {zoom}");
|
||||
}
|
||||
for size in ["(x:NaN,y:760.0)", "(x:1100.0,y:1e30)", "(x:inf,y:1.0)"] {
|
||||
let w = WINDOW.replace("(x:1100.0,y:760.0)", size);
|
||||
assert!(check(&file(&w, "1.0")).is_err(), "size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_files_are_left_to_eframe() {
|
||||
// eframe ignores files and entries it can't parse, so they can't crash it.
|
||||
for text in ["", "not ron {{{", "{\"window\":\"(((\"}", "[1,2,3]"] {
|
||||
assert_eq!(check(text), Ok(()), "{text:?}");
|
||||
}
|
||||
let deep = format!("{{\"egui\":{:?}}}", "[".repeat(500) + &"]".repeat(500));
|
||||
let _ = check(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_removes_only_a_bad_file() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("good.ron");
|
||||
let bad = dir.join("bad.ron");
|
||||
std::fs::write(&good, file(WINDOW, "1.0")).unwrap();
|
||||
std::fs::write(&bad, file(WINDOW, "0.0")).unwrap();
|
||||
super::sanitize(Some(&good));
|
||||
super::sanitize(Some(&bad));
|
||||
super::sanitize(Some(&dir.join("missing.ron")));
|
||||
super::sanitize(None);
|
||||
assert!(good.exists() && !bad.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
|
||||
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
|
||||
|
||||
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
|
||||
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
|
||||
|
||||
```sh
|
||||
photocraft-cli run input.psd \
|
||||
@@ -21,6 +21,6 @@ photocraft-cli run input.psd \
|
||||
--out output.png
|
||||
```
|
||||
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
|
||||
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
@@ -11,7 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
|
||||
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
|
||||
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
|
||||
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
|
||||
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
|
||||
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
|
||||
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |
|
||||
|
||||
@@ -22,6 +22,8 @@ Default `LoadOptions` apply these maximums:
|
||||
| One decompressed blob | 1 GiB |
|
||||
| Total decompressed tiles and blobs | 16 GiB |
|
||||
|
||||
Layer groups may nest at most 100 levels (`MAX_GROUP_DEPTH`). Saving a deeper document fails with an error instead of writing a bundle that can't be opened, and a manifest whose JSON nesting goes beyond what that depth needs is rejected before it is parsed, so a hostile file can't exhaust the stack.
|
||||
|
||||
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
|
||||
|
||||
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
# Raster formats
|
||||
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
|
||||
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
|
||||
|
||||
## Exporting documents
|
||||
|
||||
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
|
||||
|
||||
- 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.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
|
||||
@@ -19,4 +30,4 @@ Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `ima
|
||||
|
||||
`Limits::none()` exists for callers with a deliberate reason to relax policy. It remains bounded by representable memory sizes, but it removes the default resource policy and should not be used for untrusted input.
|
||||
|
||||
Animated containers are represented as a single decoded frame by the current codec API. Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
|
||||
Animated containers and multi-page TIFFs are represented as a single decoded frame or page by the current codec API; opening one adds an import warning such as "only the first of 3 frames was imported". A JPEG cut off inside its image data opens with a warning that its data ends early (an error when it holds no image data at all). Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
|
||||
@@ -7,7 +7,10 @@ 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, active TCP connections to 16, and headless/MCP batches to 256 steps.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -16,22 +19,32 @@ 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. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
|
||||
reply. 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.
|
||||
- Engine commands are expected to reject invalid parameters without panicking.
|
||||
- Remote filesystem methods use separately granted read and write directory capabilities.
|
||||
- A save without a `path` (MCP `doc_save`, `serve` `doc.save`, the control channel's `app.save`)
|
||||
writes back only to the document's own PSD, PSB or `.pcraft` file in its own format; any other
|
||||
save needs an explicit `path`, so automation never flattens or converts over the file it opened.
|
||||
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
|
||||
traversal, alternate separators, Windows device names, malformed components, and link escapes
|
||||
are rejected before file effects.
|
||||
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
|
||||
UI input reaches the same command policy, and automation-triggered file hooks do not run
|
||||
user-configured script-event paths.
|
||||
- `prefs.set` applies the engine's dotted-path normalization before checking for filesystem-bearing
|
||||
preference sections, including paths with leading or repeated dots. It refuses whole-preferences
|
||||
updates and those sections; individual safe preference keys remain available.
|
||||
- Applying the Preferences dialog with `ui.dialog.apply` is deliberately refused over automation.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- no per-client or per-tool capabilities;
|
||||
- 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;
|
||||
- one thread per accepted TCP connection;
|
||||
|
||||
@@ -39,15 +39,15 @@ A compact file declares oversized dimensions, excessive channels, nested descrip
|
||||
|
||||
### Filesystem authority abuse
|
||||
|
||||
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Current automation uses caller-supplied OS paths and has no workspace policy. This scenario is a known limitation.
|
||||
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Remote filesystem methods use separately configured directory capabilities, and engine commands that still require ambient paths fail closed at the automation boundary. This is not yet a general per-command capability model, and trusted desktop operations can still use ambient paths.
|
||||
|
||||
### Local control takeover
|
||||
|
||||
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. Loopback reduces network exposure but does not authenticate the client.
|
||||
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. A 256-bit bearer token is required before method dispatch, but authentication does not grant per-client or per-tool capabilities.
|
||||
|
||||
### Resource exhaustion
|
||||
|
||||
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Current automation transports lack explicit request, batch, and connection budgets.
|
||||
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Request-line, connection, batch, and preview limits mitigate some cases; explicit JSON-depth, document-memory, and command-duration budgets remain absent.
|
||||
|
||||
### Supply-chain compromise
|
||||
|
||||
|
||||
@@ -147,9 +147,12 @@ impl GalleryFilter {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the filter reads the foreground / background colours.
|
||||
/// Whether the filter reads the foreground / background colours. Derived
|
||||
/// from the params notation, plus the filters whose pixel code reads the
|
||||
/// colours without listing them (issue #709): Colored Pencil uses the
|
||||
/// background as paper tint, Neon Glow mixes foreground over background.
|
||||
pub fn uses_colours(self) -> bool {
|
||||
self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
|
||||
matches!(self, Self::ColoredPencil | Self::NeonGlow) || self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+165
-6
@@ -175,8 +175,13 @@ fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec<f32> {
|
||||
|
||||
/// Gaussian blur by three box passes per axis over a premultiplied window.
|
||||
fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
|
||||
box_passes(src, out, alpha, &boxes_for_gauss(sigma, 3))
|
||||
}
|
||||
|
||||
/// Box blurs of the given (odd) widths, one after another along each axis, over a premultiplied
|
||||
/// window; running sums make the cost independent of the widths.
|
||||
fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let boxes = boxes_for_gauss(sigma, 3);
|
||||
let margin: i32 = boxes.iter().map(|w| (*w as i32 - 1) / 2).sum::<i32>() + 1;
|
||||
let win = Rect::new(out.x0 - margin, out.y0 - margin, out.x1 + margin, out.y1 + margin);
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
@@ -197,11 +202,11 @@ fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
p.chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row));
|
||||
premultiply(&mut p, n, alpha);
|
||||
for w in &boxes {
|
||||
for w in boxes {
|
||||
box_rows(&mut p, ww, n, (w - 1) / 2);
|
||||
}
|
||||
let mut t = transpose(&p, ww, wh, n);
|
||||
for w in &boxes {
|
||||
for w in boxes {
|
||||
box_rows(&mut t, wh, n, (w - 1) / 2);
|
||||
}
|
||||
let p = transpose(&t, wh, ww, n);
|
||||
@@ -217,8 +222,12 @@ fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
|
||||
|
||||
pub(crate) fn boxed(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
let r = radius.max(0.0).round() as usize;
|
||||
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
|
||||
conv_sep(src, out, &k, &k, ctx.alpha)
|
||||
// Running sums cost the same at any radius; the direct kernel is quicker for the smallest.
|
||||
if r <= 4 {
|
||||
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
|
||||
return conv_sep(src, out, &k, &k, ctx.alpha);
|
||||
}
|
||||
box_passes(src, out, ctx.alpha, &[2 * r + 1])
|
||||
}
|
||||
|
||||
fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f32, f32, &mut Vec<(f32, f32)>)) -> Vec<f32> {
|
||||
@@ -305,11 +314,102 @@ pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: Rad
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
|
||||
/// [`surface`] for 8-bit samples (every colour sample in the window is `k / 255`; `None`
|
||||
/// otherwise): a 256-bin histogram of each channel over the window slides along the row, adding
|
||||
/// and removing one column per step, so a pixel costs O(radius + levels) instead of O(radius^2).
|
||||
/// Each level is weighted with the same expression as the direct sum.
|
||||
fn surface_8bit(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32, reach: usize) -> Option<Vec<f32>> {
|
||||
let n = src.ch;
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let win = out.inflate(r);
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
// Level of every colour sample in the window; `SKIP` where the pixel is transparent (the
|
||||
// direct sum skips those). Outside the source reads as 0, as `Image::get` does.
|
||||
const SKIP: u16 = 256;
|
||||
let mut lv = vec![0u16; ww * wh * cc];
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
let i = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * cc;
|
||||
let skip = ctx.alpha && src.get(x, y, n - 1) <= 0.0;
|
||||
for c in 0..cc {
|
||||
let v = src.get(x, y, c);
|
||||
let k = (v * 255.0).round();
|
||||
if !(0.0..=255.0).contains(&k) || (k / 255.0).to_bits() != v.to_bits() {
|
||||
return None;
|
||||
}
|
||||
lv[i + c] = if skip { SKIP } else { k as u16 };
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let side = (2 * r + 1) as usize;
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
let mut hist = [0u32; 257];
|
||||
for oy in 0..oh {
|
||||
for c in 0..cc {
|
||||
hist.fill(0);
|
||||
// Window of output column 0: window rows oy..oy + side, columns 0..side.
|
||||
let col = |hist: &mut [u32; 257], wx: usize, add: bool| {
|
||||
for wy in oy..oy + side {
|
||||
let k = lv[(wy * ww + wx) * cc + c] as usize;
|
||||
if add {
|
||||
hist[k] += 1;
|
||||
} else {
|
||||
hist[k] -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
for wx in 0..side {
|
||||
col(&mut hist, wx, true);
|
||||
}
|
||||
for ox in 0..ow {
|
||||
let p = src.px(out.x0 + ox as i32, out.y0 + oy as i32);
|
||||
let v0 = p[c];
|
||||
let k0 = (v0 * 255.0).round() as usize;
|
||||
let (mut acc, mut wsum) = (0.0f64, 0.0f64);
|
||||
for (k, &cnt) in hist.iter().enumerate().take(256.min(k0 + reach + 1)).skip(k0.saturating_sub(reach)) {
|
||||
if cnt == 0 {
|
||||
continue;
|
||||
}
|
||||
let v = k as f32 / 255.0;
|
||||
let w = (1.0 - (v - v0).abs() / t).max(0.0);
|
||||
acc += f64::from(v * w) * f64::from(cnt);
|
||||
wsum += f64::from(w) * f64::from(cnt);
|
||||
}
|
||||
res[(oy * ow + ox) * n + c] = if wsum > 0.0 { (acc / wsum) as f32 } else { v0 };
|
||||
if ox + 1 < ow {
|
||||
col(&mut hist, ox, false);
|
||||
col(&mut hist, ox + side, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.alpha {
|
||||
for ox in 0..ow {
|
||||
res[(oy * ow + ox) * n + n - 1] = src.px(out.x0 + ox as i32, out.y0 + oy as i32)[n - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
|
||||
let r = radius.max(0.0).round() as i32;
|
||||
let t = (threshold.max(1.0) / 255.0) * 2.5;
|
||||
// Levels within the threshold of the centre (the only ones with any weight), on each side.
|
||||
let reach = ((t * 255.0).ceil() as usize).min(255);
|
||||
let side = (2 * r + 1) as usize;
|
||||
if side * side > 2 * reach + 1 + 4 * side
|
||||
&& let Some(res) = surface_8bit(src, out, ctx, r, t, reach)
|
||||
{
|
||||
return res;
|
||||
}
|
||||
surface_direct(src, out, ctx, r, t)
|
||||
}
|
||||
|
||||
/// [`surface`] by summing the whole (2r + 1)^2 window of every pixel.
|
||||
fn surface_direct(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
|
||||
for y in out.y0..out.y1 {
|
||||
for x in out.x0..out.x1 {
|
||||
@@ -361,4 +461,63 @@ mod tests {
|
||||
assert!(err < 0.02, "max error {err}");
|
||||
assert_eq!(boxes_for_gauss(10.0, 3).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_passes_match_the_direct_box_kernel() {
|
||||
// Noisy RGBA with varying alpha, so premultiplication and every channel are exercised.
|
||||
for r in [5usize, 17, 60] {
|
||||
let (w, h, m) = (40, 24, r as i32 + 2);
|
||||
let src_rect = Rect::new(-m, -m, w + m, h + m);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x9e37_79b9u32;
|
||||
for v in img.data.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 256) as f32 / 255.0;
|
||||
}
|
||||
let out = Rect::new(0, 0, w, h);
|
||||
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
|
||||
let direct = conv_sep(&img, out, &k, &k, true);
|
||||
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
|
||||
let fast = boxed(&img, out, &ctx, r as f32);
|
||||
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err < 1e-5, "radius {r}: max error {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_blur_8bit_histogram_matches_the_direct_sum() {
|
||||
// 8-bit levels with some fully transparent pixels, which the sum skips.
|
||||
let src_rect = Rect::new(-14, -14, 40, 34);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x6c07_8965u32;
|
||||
for px in img.data.as_chunks_mut::<4>().0 {
|
||||
for v in px.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 256) as f32 / 255.0;
|
||||
}
|
||||
if s.is_multiple_of(7) {
|
||||
px[3] = 0.0;
|
||||
}
|
||||
}
|
||||
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
|
||||
// The output reaches the source edge, where samples read as transparent.
|
||||
let out = Rect::new(-2, 0, 26, 20);
|
||||
for (r, threshold) in [(1, 2.0), (4, 15.0), (12, 60.0), (12, 255.0)] {
|
||||
let t = (threshold / 255.0) * 2.5;
|
||||
let reach = ((t * 255.0f32).ceil() as usize).min(255);
|
||||
let fast = surface_8bit(&img, out, &ctx, r, t, reach).expect("8-bit input");
|
||||
let direct = surface_direct(&img, out, &ctx, r, t);
|
||||
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err < 1e-5, "r {r} threshold {threshold}: max error {err}");
|
||||
}
|
||||
// Not 8-bit: the histogram declines and the direct sum is used.
|
||||
// (0, 0) is inside every window above.
|
||||
let i = (14 * src_rect.width() as usize + 14) * 4;
|
||||
img.data[i] = 0.5 / 255.0;
|
||||
assert!(surface_8bit(&img, out, &ctx, 4, 0.1, 26).is_none());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,29 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most points a Camera Raw point curve accepts from commands and the control channel.
|
||||
pub const MAX_CURVE_POINTS: usize = 16;
|
||||
|
||||
/// Checks a point curve from untrusted input: empty (linear) or 2..=16 finite points in 0..=255
|
||||
/// with inputs increasing by at least one level. Deserialization stays lenient on purpose:
|
||||
/// earlier editors saved curves this rejects, and [`curve_lut`] already sorts and de-duplicates
|
||||
/// them, so stored Smart Filters keep rendering.
|
||||
pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
|
||||
if points.len() == 1 {
|
||||
return Err("a nonempty curve needs at least two points".into());
|
||||
}
|
||||
if points.len() > MAX_CURVE_POINTS {
|
||||
return Err(format!("at most {MAX_CURVE_POINTS} curve points"));
|
||||
}
|
||||
if !points.iter().flatten().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) {
|
||||
return Err("curve coordinates must be finite and in 0..=255".into());
|
||||
}
|
||||
if points.windows(2).any(|w| w[1][0] - w[0][0] < 1.0) {
|
||||
return Err("curve inputs must increase by at least one level".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
|
||||
|
||||
/// One colour grading wheel.
|
||||
@@ -437,6 +460,33 @@ pub fn band_weights(h: f32) -> [f32; 8] {
|
||||
}
|
||||
|
||||
impl CameraRaw {
|
||||
/// Rejects malformed point curves in new settings (see [`validate_curve`]).
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
for (name, curve) in [
|
||||
("pointCurve", &self.point_curve),
|
||||
("pointCurveRed", &self.point_curve_red),
|
||||
("pointCurveGreen", &self.point_curve_green),
|
||||
("pointCurveBlue", &self.point_curve_blue),
|
||||
] {
|
||||
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// [`validate`](Self::validate) for an edit of `stored` settings: a curve left as stored is
|
||||
/// accepted, so editing another control of an older Smart Filter still works.
|
||||
pub fn validate_changes(&self, stored: &CameraRaw) -> Result<(), String> {
|
||||
for (name, curve, kept) in [
|
||||
("pointCurve", &self.point_curve, &stored.point_curve),
|
||||
("pointCurveRed", &self.point_curve_red, &stored.point_curve_red),
|
||||
("pointCurveGreen", &self.point_curve_green, &stored.point_curve_green),
|
||||
("pointCurveBlue", &self.point_curve_blue, &stored.point_curve_blue),
|
||||
] {
|
||||
if curve != kept {
|
||||
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn wb_neutral(&self) -> bool {
|
||||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
@@ -869,6 +919,44 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
|
||||
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
|
||||
for points in [
|
||||
serde_json::json!([[128, 128]]),
|
||||
serde_json::json!([[255, 255], [0, 0]]),
|
||||
serde_json::json!([[0, 0], [0, 255]]),
|
||||
serde_json::json!([[0, -1], [255, 255]]),
|
||||
serde_json::json!([[0, 0], [256, 255]]),
|
||||
serde_json::json!([[0, 0], [1e30, 255]]),
|
||||
serde_json::json!((0..17).map(|i| [i * 15, i * 15]).collect::<Vec<_>>()),
|
||||
] {
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
|
||||
assert!(p.validate().is_err(), "{field}: {points}");
|
||||
}
|
||||
for points in
|
||||
[serde_json::json!([]), serde_json::json!([[0, 0], [255, 255]]), serde_json::json!((0..16).map(|i| [i * 17, i * 17]).collect::<Vec<_>>())]
|
||||
{
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
|
||||
assert!(p.validate().is_ok(), "{field}: {points}");
|
||||
}
|
||||
}
|
||||
assert_eq!(serde_json::from_str::<CameraRaw>("{}").unwrap(), CameraRaw::default());
|
||||
// An older editor could stack two points on one input and exceed 16 points; such a
|
||||
// saved Smart Filter must still develop instead of silently disappearing.
|
||||
let mut legacy: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 12.0, i as f32 * 12.0]).collect();
|
||||
legacy.insert(5, [60.0, 200.0]);
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({"pointCurve": legacy})).unwrap();
|
||||
assert!(p.validate().is_err());
|
||||
let edited = CameraRaw { exposure: 1.0, ..p.clone() };
|
||||
assert!(edited.validate_changes(&p).is_ok(), "an unchanged stored curve stays editable");
|
||||
let broken = CameraRaw { point_curve: vec![[10.0, 10.0]], ..p.clone() };
|
||||
assert!(broken.validate_changes(&p).is_err(), "a changed curve is validated");
|
||||
let mut px = img(4, 4);
|
||||
develop(&mut px, 4, 4, &p, false);
|
||||
assert!(px.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
fn img(w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//!
|
||||
//! Buffers are interleaved `w × h × ch` normalised floats (any model/depth); deterministic.
|
||||
|
||||
use crate::inpaint::{CompleteParams, complete};
|
||||
use crate::poisson::membrane_fill;
|
||||
use crate::inpaint::{CompleteParams, complete_with};
|
||||
use crate::poisson::membrane_fill_with;
|
||||
|
||||
/// Options for [`fill`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -161,11 +161,27 @@ fn box_blur(w: usize, h: usize, ch: usize, v: &[f32], r: usize) -> Vec<f32> {
|
||||
/// Returns the full buffer (unchanged outside the hole). Falls back to the membrane fill when
|
||||
/// nothing can be sampled.
|
||||
pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &[bool], opts: &FillOptions) -> Vec<f32> {
|
||||
// Never cancelled; the fallback (the input unchanged) is unreachable.
|
||||
fill_with(w, h, ch, img, hole, source, opts, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
|
||||
}
|
||||
|
||||
/// [`fill`] that can be cancelled (see [`crate::inpaint::complete_with`]) and reports progress.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[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 img.to_vec();
|
||||
return Ok(img.to_vec());
|
||||
}
|
||||
let params = CompleteParams { seed: opts.seed, ..Default::default() };
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
@@ -208,7 +224,11 @@ pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
let filled = complete(mw, mh, ch, &mimg, &unknown, ¶ms);
|
||||
ctl.check()?;
|
||||
// The completion is nearly all the work: it reports 0–90 %.
|
||||
let cancel = || ctl.cancelled();
|
||||
let progress = |f: f32| ctl.progress(f * 0.9);
|
||||
let filled = complete_with(mw, mh, ch, &mimg, &unknown, ¶ms, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
|
||||
let mut out = img.to_vec();
|
||||
match filled {
|
||||
Some(f) => {
|
||||
@@ -221,16 +241,28 @@ pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &
|
||||
}
|
||||
}
|
||||
}
|
||||
None => out = membrane_fill(w, h, ch, img, hole),
|
||||
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
|
||||
}
|
||||
ctl.check()?;
|
||||
if opts.color_adaptation > 0.0 {
|
||||
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation);
|
||||
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation, ctl)?;
|
||||
}
|
||||
out
|
||||
ctl.progress(1.0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pull the fill's low frequencies towards the membrane interpolation of the hole boundary.
|
||||
fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out: &mut [f32], k: f32) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn adapt_colors(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
out: &mut [f32],
|
||||
k: f32,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<(), photocraft_raster::Cancelled> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
@@ -240,7 +272,9 @@ fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out:
|
||||
}
|
||||
}
|
||||
let r = ((x1 - x0).max(y1 - y0) / 6).clamp(1, 32);
|
||||
let membrane = membrane_fill(w, h, ch, img, hole);
|
||||
let membrane = membrane_fill_with(w, h, ch, img, hole, ctl)?;
|
||||
ctl.progress(0.97);
|
||||
ctl.check()?;
|
||||
let low = box_blur(w, h, ch, out, r);
|
||||
for (i, &hl) in hole.iter().enumerate() {
|
||||
if hl {
|
||||
@@ -250,6 +284,7 @@ fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out:
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -263,6 +298,26 @@ mod tests {
|
||||
(img, hole)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_fill_stops_and_progress_reaches_one() {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
let (w, h) = (60, 40);
|
||||
let (img, hole) = stripes(w, h);
|
||||
let source = vec![true; w * h];
|
||||
let yes = || true;
|
||||
let cancelled = photocraft_raster::Interrupt::cancel_only(&yes);
|
||||
assert!(fill_with(w, h, 1, &img, &hole, &source, &FillOptions::default(), &cancelled).is_err());
|
||||
let last = AtomicU32::new(0);
|
||||
let no = || false;
|
||||
let progress = |f: f32| {
|
||||
assert!(f >= f32::from_bits(last.load(Ordering::Relaxed)), "progress went backwards");
|
||||
last.store(f.to_bits(), Ordering::Relaxed);
|
||||
};
|
||||
let out = fill_with(w, h, 1, &img, &hole, &source, &FillOptions::default(), &photocraft_raster::Interrupt::new(&no, &progress)).unwrap();
|
||||
assert_eq!(f32::from_bits(last.load(Ordering::Relaxed)), 1.0);
|
||||
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_only_the_hole_with_known_texture() {
|
||||
let (w, h) = (48, 32);
|
||||
|
||||
@@ -172,7 +172,17 @@ pub(crate) fn gauss_box_radii(sigma: f32) -> [usize; 3] {
|
||||
let wl = wl.max(1);
|
||||
let wu = wl + 2;
|
||||
let m = ((12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0)).round().clamp(0.0, n) as usize;
|
||||
std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 })
|
||||
let mut radii = std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 });
|
||||
// Kovesi's rounded triple degenerates below sigma ~ 0.577: `wl` floors to 1 and every
|
||||
// radius becomes 0, so the passes would be no-ops and callers with documented small
|
||||
// sigmas (Camera Raw `sharpenRadius` 0.5, colour noise, HDR Toning `radius` 1) would
|
||||
// silently change nothing (issue #706). One radius-1 pass is the smallest real blur;
|
||||
// it only engages where the ideal widths are already at their floor, leaving larger
|
||||
// sigmas exactly as the paper prescribes.
|
||||
if radii.iter().all(|&r| r == 0) {
|
||||
radii[0] = 1;
|
||||
}
|
||||
radii
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur (three box passes) of an interleaved buffer.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Bounded RGB histograms of straight, normalized float RGBA samples.
|
||||
//!
|
||||
//! No colour conversion is performed: bins describe the supplied sample domain, not an
|
||||
//! assumed sRGB/ICC display space. 256 bins are a display resolution, not an input bit depth.
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RgbHistogram {
|
||||
pub channels: [[u64; BINS]; 3],
|
||||
pub samples: u64,
|
||||
pub transparent: u64,
|
||||
pub invalid: u64,
|
||||
/// Finite samples outside 0..=1 are included in the edge bins and counted separately.
|
||||
pub underflow: [u64; 3],
|
||||
pub overflow: [u64; 3],
|
||||
/// Exact endpoints, before display-bin rounding. A near-white value is not clipped.
|
||||
pub shadows: [u64; 3],
|
||||
pub highlights: [u64; 3],
|
||||
}
|
||||
|
||||
impl Default for RgbHistogram {
|
||||
fn default() -> Self {
|
||||
Self { channels: [[0; BINS]; 3], samples: 0, transparent: 0, invalid: 0, underflow: [0; 3], overflow: [0; 3], shadows: [0; 3], highlights: [0; 3] }
|
||||
}
|
||||
}
|
||||
|
||||
impl RgbHistogram {
|
||||
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
|
||||
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
|
||||
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
|
||||
let mut h = Self::default();
|
||||
for pixel in pixels {
|
||||
if !pixel[3].is_finite() {
|
||||
h.invalid = h.invalid.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if pixel[3] <= 0.0 {
|
||||
h.transparent = h.transparent.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if !pixel.iter().all(|v| v.is_finite()) {
|
||||
h.invalid = h.invalid.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
h.samples = h.samples.saturating_add(1);
|
||||
let (low, high) = clipping(*pixel);
|
||||
for (c, ((((bins, below), above), shadows), highlights)) in
|
||||
h.channels.iter_mut().zip(&mut h.underflow).zip(&mut h.overflow).zip(&mut h.shadows).zip(&mut h.highlights).enumerate()
|
||||
{
|
||||
let Some(value) = pixel.get(c) else { continue };
|
||||
if low & (1 << c) != 0 {
|
||||
*shadows = shadows.saturating_add(1);
|
||||
}
|
||||
if high & (1 << c) != 0 {
|
||||
*highlights = highlights.saturating_add(1);
|
||||
}
|
||||
if *value < 0.0 {
|
||||
*below = below.saturating_add(1);
|
||||
} else if *value > 1.0 {
|
||||
*above = above.saturating_add(1);
|
||||
}
|
||||
// Match the preview encoder's nearest-bin rule, before it loses float precision.
|
||||
let bin = (value.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize;
|
||||
if let Some(count) = bins.get_mut(bin) {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
|
||||
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
|
||||
/// Transparent and non-finite pixels clip nothing.
|
||||
pub fn clipping(p: [f32; 4]) -> (u8, u8) {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut shadows = 0;
|
||||
let mut highlights = 0;
|
||||
for (c, v) in p.iter().take(3).enumerate() {
|
||||
if *v <= 0.0 {
|
||||
shadows |= 1 << c;
|
||||
}
|
||||
if *v >= 1.0 {
|
||||
highlights |= 1 << c;
|
||||
}
|
||||
}
|
||||
(shadows, highlights)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_bins_and_channel_totals() {
|
||||
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!(h.samples, 256);
|
||||
assert!(h.channels[0].iter().all(|&v| v == 1));
|
||||
assert!(h.channels[1].iter().all(|&v| v == 1));
|
||||
assert_eq!(h.channels[2][128], 256);
|
||||
for channel in h.channels {
|
||||
assert_eq!(channel.iter().sum::<u64>(), h.samples);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_transparent_invalid_and_hdr_are_explicit() {
|
||||
assert_eq!(RgbHistogram::from_rgba(&[]), RgbHistogram::default());
|
||||
let pixels = [[0.7, 0.9, 0.4, 0.0], [f32::NAN, 0.0, 0.0, 1.0], [0.0, f32::INFINITY, 0.0, 1.0], [0.0, 0.0, 0.0, f32::NAN], [-2.0, 4.0, 0.5, 1.0]];
|
||||
let original = pixels;
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!((h.samples, h.transparent, h.invalid), (1, 1, 3));
|
||||
assert_eq!(h.underflow, [1, 0, 0]);
|
||||
assert_eq!(h.overflow, [0, 1, 0]);
|
||||
assert_eq!((h.channels[0][0], h.channels[1][255], h.channels[2][128]), (1, 1, 1));
|
||||
assert_eq!(pixels[4], original[4]);
|
||||
assert!(pixels[1][0].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_indicators_use_exact_samples_not_edge_bin_rounding() {
|
||||
let h = RgbHistogram::from_rgba(&[[0.0001, 0.9999, 0.5, 1.0], [-0.2, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0]]);
|
||||
assert_eq!(h.shadows, [1, 0, 1]);
|
||||
assert_eq!(h.highlights, [0, 1, 0]);
|
||||
assert_eq!(h.channels[0][0], 2);
|
||||
assert_eq!(h.channels[1][255], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_and_gray_at_all_depths_use_the_same_float_contract() {
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for sample in SampleType::ALL {
|
||||
let mut surface = Surface::new(PixelFormat::new(mode, sample, true));
|
||||
for (x, v) in [0.0, 0.25, 0.75, 1.0].into_iter().enumerate() {
|
||||
let px = if mode == ColorMode::Rgb { vec![v, v, v, 1.0] } else { vec![v, 1.0] };
|
||||
surface.write_pixel(x as i32, 0, &px);
|
||||
}
|
||||
let mut pixels = [[0.0; 4]; 4];
|
||||
surface.read_rgba_into(Rect::new(0, 0, 4, 1), &mut pixels);
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!(h.samples, 4, "{mode:?}/{sample:?}");
|
||||
for c in &h.channels {
|
||||
for bin in [0, 64, 191, 255] {
|
||||
assert_eq!(c[bin], 1, "{mode:?}/{sample:?}, bin {bin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_clipping_endpoints_not_quantized_neighbours() {
|
||||
assert_eq!(clipping([0.0001, 0.9999, 0.5, 1.0]), (0, 0));
|
||||
assert_eq!(clipping([-0.1, 1.0, 0.0, 1.0]), (5, 2));
|
||||
assert_eq!(clipping([0.0, 1.0, 0.0, 0.0]), (0, 0));
|
||||
assert_eq!(clipping([0.0, f32::INFINITY, 0.0, 1.0]), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_of_an_eight_bit_gradient_reproduces_the_quantization_comb() {
|
||||
let mut pixels: Vec<_> = (0..256).map(|i| [i as f32 / 255.0, i as f32 / 255.0, i as f32 / 255.0, 1.0]).collect();
|
||||
assert!(RgbHistogram::from_rgba(&pixels).channels[0].iter().all(|&v| v == 1));
|
||||
crate::camera_raw::develop(&mut pixels, 256, 1, &crate::camera_raw::CameraRaw { exposure: 0.5, ..Default::default() }, true);
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert!(h.channels[0][10..230].iter().filter(|&&v| v == 0).count() > 10);
|
||||
assert_eq!(h.samples, 256);
|
||||
assert!(h.overflow[0] > 0);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,8 @@ struct Solver<'a> {
|
||||
valid_list: Vec<(i32, i32)>,
|
||||
/// Target centres (patch overlaps the hole), row-major.
|
||||
targets: Vec<bool>,
|
||||
/// Cancellation: checked per row band; a cancelled solve's output is discarded.
|
||||
ctl: photocraft_raster::Interrupt<'a>,
|
||||
}
|
||||
|
||||
impl Solver<'_> {
|
||||
@@ -211,6 +213,9 @@ impl Solver<'_> {
|
||||
let w = self.w;
|
||||
let reverse = pass % 2 == 1;
|
||||
let band_fn = |bi: usize, nn: &mut [(i32, i32)], co: &mut [f32]| {
|
||||
if self.ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
let mut rng = Rng::new(seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40));
|
||||
let y0 = bi * BAND;
|
||||
let rows = nn.len() / w;
|
||||
@@ -292,6 +297,9 @@ impl Solver<'_> {
|
||||
let img = &*self.img;
|
||||
let row = |y: usize| -> Vec<(usize, Vec<f32>)> {
|
||||
let mut out = Vec::new();
|
||||
if self.ctl.cancelled() {
|
||||
return out;
|
||||
}
|
||||
for x in 0..w {
|
||||
if !self.hole[y * w + x] {
|
||||
continue;
|
||||
@@ -349,9 +357,24 @@ impl Solver<'_> {
|
||||
/// Content-aware completion of `hole` (see module docs). Returns `None` when there is no fully-known
|
||||
/// patch to copy from (the caller can fall back to [`membrane_fill`]).
|
||||
pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &CompleteParams) -> Option<Vec<f32>> {
|
||||
// Never cancelled, so never `Err`.
|
||||
complete_with(w, h, ch, img, hole, p, &photocraft_raster::Interrupt::NONE).unwrap_or(None)
|
||||
}
|
||||
|
||||
/// [`complete`] that can be cancelled (checked per EM iteration, PatchMatch pass and row band)
|
||||
/// and reports progress (by pyramid level). `Err(Cancelled)` when `ctl` was cancelled.
|
||||
pub fn complete_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
p: &CompleteParams,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Option<Vec<f32>>, photocraft_raster::Cancelled> {
|
||||
assert_eq!(img.len(), w * h * ch);
|
||||
assert_eq!(hole.len(), w * h);
|
||||
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Some(img.to_vec()) };
|
||||
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Ok(Some(img.to_vec())) };
|
||||
let r = p.patch_radius.max(1);
|
||||
let psz = 2 * r + 1;
|
||||
// Pyramid: shrink until the hole is a few patches across.
|
||||
@@ -359,6 +382,7 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
let mut ext = (bx1 - bx0).max(by1 - by0);
|
||||
let mut exts = vec![ext];
|
||||
while let Some(l) = levels.last() {
|
||||
ctl.check()?;
|
||||
if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
@@ -370,7 +394,10 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
let nlev = levels.len();
|
||||
let mut nnf: Vec<(i32, i32)> = Vec::new();
|
||||
let mut prev: Option<(usize, usize, Vec<f32>)> = None;
|
||||
let total_px: usize = levels.iter().map(|l| l.w * l.h).sum();
|
||||
let mut done_px = 0usize;
|
||||
for li in (0..nlev).rev() {
|
||||
ctl.check()?;
|
||||
let Level { w: lw, h: lh, img: mut limg, hole: lhole } = levels[li].clone();
|
||||
// Initialise the hole: membrane at the coarsest level, upsampled result elsewhere.
|
||||
match &prev {
|
||||
@@ -393,6 +420,9 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
let mut valid_list = Vec::new();
|
||||
let mut targets = vec![false; lw * lh];
|
||||
for y in 0..lh as i32 {
|
||||
if y % 32 == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
for x in 0..lw as i32 {
|
||||
let cnt = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1);
|
||||
let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32;
|
||||
@@ -406,19 +436,22 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
}
|
||||
if valid_list.is_empty() {
|
||||
if li == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
prev = Some((lw, lh, limg));
|
||||
nnf.clear();
|
||||
continue;
|
||||
}
|
||||
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets };
|
||||
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets, ctl: *ctl };
|
||||
// NNF init: upsampled from the coarser level where possible, random otherwise.
|
||||
let mut rng = Rng::new(p.seed ^ (li as u64) << 20);
|
||||
let pw_prev = prev.as_ref().map_or(0, |p| p.0);
|
||||
let old = std::mem::take(&mut nnf);
|
||||
let mut new_nnf = vec![(0i32, 0i32); lw * lh];
|
||||
for y in 0..lh {
|
||||
if y % 32 == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
for x in 0..lw {
|
||||
if !solver.targets[y * lw + x] {
|
||||
continue;
|
||||
@@ -451,6 +484,9 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
for it in 0..em {
|
||||
// Costs change as hole pixels are re-estimated: refresh before each PatchMatch.
|
||||
for (i, c) in cost.iter_mut().enumerate() {
|
||||
if i % (lw * 32).max(1) == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
if solver.targets[i] {
|
||||
*c = solver.dist(((i % lw) as i32, (i / lw) as i32), nnf[i], f32::INFINITY);
|
||||
}
|
||||
@@ -464,13 +500,19 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
|
||||
(4 * psz as i32).min(full_search)
|
||||
};
|
||||
for pass in 0..pm_iters {
|
||||
ctl.check()?;
|
||||
solver.patchmatch(&mut nnf, &mut cost, pass + it * pm_iters, p.seed.wrapping_add((li * 1000 + it) as u64), radius);
|
||||
}
|
||||
ctl.check()?;
|
||||
solver.vote(&nnf, &cost);
|
||||
ctl.check()?;
|
||||
}
|
||||
// Finer levels cost ~4× the previous one: weight progress by pixel count.
|
||||
done_px += lw * lh;
|
||||
ctl.progress(done_px as f32 / total_px.max(1) as f32);
|
||||
prev = Some((lw, lh, limg));
|
||||
}
|
||||
prev.map(|(_, _, img)| img)
|
||||
Ok(prev.map(|(_, _, img)| img))
|
||||
}
|
||||
|
||||
/// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the
|
||||
|
||||
+75
-2
@@ -33,6 +33,7 @@ pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
pub mod hdr;
|
||||
pub mod histogram;
|
||||
mod image;
|
||||
pub mod inpaint;
|
||||
pub mod lens;
|
||||
@@ -51,6 +52,7 @@ pub mod poisson;
|
||||
pub mod puppet;
|
||||
pub mod pyramid;
|
||||
pub mod quantize;
|
||||
mod relight;
|
||||
mod render;
|
||||
pub mod render2;
|
||||
pub mod resample;
|
||||
@@ -68,6 +70,7 @@ pub mod tone;
|
||||
pub mod transform;
|
||||
pub mod trap;
|
||||
pub mod vanishing;
|
||||
pub mod vectorscope;
|
||||
mod video;
|
||||
pub mod warp;
|
||||
pub mod wideangle;
|
||||
@@ -409,6 +412,16 @@ pub enum FilterParams {
|
||||
height: f32,
|
||||
white_is_high: bool,
|
||||
},
|
||||
/// Photographic relight: angle −180…180°, elevation 0…90°, intensity/ambient 0…100,
|
||||
/// warmth −100…100, softness 1…100. Intensity 0 is an identity copy.
|
||||
Relight {
|
||||
angle: f32,
|
||||
elevation: f32,
|
||||
intensity: f32,
|
||||
ambient: f32,
|
||||
warmth: f32,
|
||||
softness: f32,
|
||||
},
|
||||
|
||||
// ---- Noise ----
|
||||
/// Strength 0–10, the rest 0–100 %.
|
||||
@@ -585,6 +598,7 @@ impl FilterParams {
|
||||
FilterParams::Fibers { .. } => "Fibers",
|
||||
FilterParams::LensFlare { .. } => "Lens Flare",
|
||||
FilterParams::LightingEffects { .. } => "Lighting Effects",
|
||||
FilterParams::Relight { .. } => "Relight",
|
||||
FilterParams::ReduceNoise { .. } => "Reduce Noise",
|
||||
FilterParams::SmartBlur { .. } => "Smart Blur",
|
||||
FilterParams::LensBlur { .. } => "Lens Blur",
|
||||
@@ -626,6 +640,8 @@ fn halo_ext(p: &FilterParams) -> Halo {
|
||||
// Generators and per-pixel renders only need the bounds geometry (always in the context).
|
||||
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
|
||||
FilterParams::LightingEffects { .. } => r(1.0),
|
||||
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
|
||||
FilterParams::Relight { .. } => Halo::Radius(relight::HALO_RADIUS),
|
||||
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
|
||||
FilterParams::SmartBlur { radius, .. } => r(*radius),
|
||||
FilterParams::LensBlur { radius, .. } => r(*radius),
|
||||
@@ -759,6 +775,12 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
white_is_high: *white_is_high,
|
||||
},
|
||||
),
|
||||
FilterParams::Relight { angle, elevation, intensity, ambient, warmth, softness } => relight::relight(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
relight::Params { angle: *angle, elevation: *elevation, intensity: *intensity, ambient: *ambient, warmth: *warmth, softness: *softness },
|
||||
),
|
||||
FilterParams::ReduceNoise { strength, preserve_details, reduce_color_noise, sharpen_details, remove_jpeg_artifact } => denoise::reduce_noise(
|
||||
src,
|
||||
out,
|
||||
@@ -861,6 +883,20 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
|
||||
apply_tiled(surface, params, area.intersect(&extent), bounds, selection, auto_tile(params), Some(extent))
|
||||
}
|
||||
|
||||
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
|
||||
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
|
||||
pub fn apply_in_with(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
area: Rect,
|
||||
bounds: Rect,
|
||||
selection: Option<&Surface>,
|
||||
extent: Rect,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
apply_tiled_with(surface, params, area.intersect(&extent), bounds, selection, auto_tile(params), Some(extent), ctl)
|
||||
}
|
||||
|
||||
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
|
||||
/// re-read margin (and its cost) below ~2× the tile area.
|
||||
fn auto_tile(params: &FilterParams) -> i32 {
|
||||
@@ -880,9 +916,26 @@ pub fn apply_tiled(
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
) -> Surface {
|
||||
// Never cancelled, so always `Some`; the fallback (the input unchanged) is unreachable.
|
||||
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
|
||||
}
|
||||
|
||||
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
|
||||
/// one tile's work) and progress (after each group of tiles). `None` when cancelled.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply_tiled_with(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
area: Rect,
|
||||
bounds: Rect,
|
||||
selection: Option<&Surface>,
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
let mut out = surface.clone();
|
||||
if area.is_empty() {
|
||||
return out;
|
||||
return Some(out);
|
||||
}
|
||||
let fmt = surface.format();
|
||||
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
|
||||
@@ -898,7 +951,19 @@ pub fn apply_tiled(
|
||||
}
|
||||
y += tile;
|
||||
}
|
||||
// Tiles finished so far, for progress reported per tile (from any worker thread).
|
||||
let finished = std::sync::atomic::AtomicUsize::new(0);
|
||||
let total = tiles.len().max(1);
|
||||
let run = |t: &Rect| -> (Rect, Vec<f32>) {
|
||||
// Cancelled: skip the remaining tiles of the group (the result is discarded).
|
||||
if ctl.cancelled() {
|
||||
return (*t, Vec::new());
|
||||
}
|
||||
let tick = || {
|
||||
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes after each group.
|
||||
ctl.progress(0.98 * n as f32 / total as f32);
|
||||
};
|
||||
let owned;
|
||||
let src = match (&shared, halo) {
|
||||
(Some(s), _) => s,
|
||||
@@ -931,6 +996,7 @@ pub fn apply_tiled(
|
||||
}
|
||||
}
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
};
|
||||
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
|
||||
@@ -943,6 +1009,9 @@ pub fn apply_tiled(
|
||||
let per_tile = (tile.max(1) as usize).pow(2) * fmt.channels() * std::mem::size_of::<f32>();
|
||||
let group = (RESULT_BUDGET / per_tile.max(1)).max(threads).max(1);
|
||||
for chunk in tiles.chunks(group) {
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let results: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
@@ -950,12 +1019,16 @@ pub fn apply_tiled(
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
for (t, data) in results {
|
||||
out.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
ctl.progress(1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Bytes of float filter results held at once by [`apply_tiled`].
|
||||
|
||||
+144
-3
@@ -38,6 +38,7 @@ pub enum LiquifyTool {
|
||||
PushLeft,
|
||||
Freeze,
|
||||
Thaw,
|
||||
LassoMask,
|
||||
/// The Reconstruct… button: scales the whole (unfrozen) field toward zero by `amount` %.
|
||||
ReconstructAll,
|
||||
/// Mask Options › Mask All / None / Invert All (whole-field freeze edits).
|
||||
@@ -47,7 +48,7 @@ pub enum LiquifyTool {
|
||||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub const ALL: [LiquifyTool; 10] = [
|
||||
pub const ALL: [LiquifyTool; 11] = [
|
||||
LiquifyTool::ForwardWarp,
|
||||
LiquifyTool::Reconstruct,
|
||||
LiquifyTool::Smooth,
|
||||
@@ -58,6 +59,7 @@ impl LiquifyTool {
|
||||
LiquifyTool::PushLeft,
|
||||
LiquifyTool::Freeze,
|
||||
LiquifyTool::Thaw,
|
||||
LiquifyTool::LassoMask,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -72,6 +74,7 @@ impl LiquifyTool {
|
||||
LiquifyTool::PushLeft => "Push Left",
|
||||
LiquifyTool::Freeze => "Freeze Mask",
|
||||
LiquifyTool::Thaw => "Thaw Mask",
|
||||
LiquifyTool::LassoMask => "Freeze Lasso",
|
||||
LiquifyTool::ReconstructAll => "Reconstruct All",
|
||||
LiquifyTool::FreezeAll => "Mask All",
|
||||
LiquifyTool::ThawAll => "Mask None",
|
||||
@@ -86,7 +89,7 @@ impl LiquifyTool {
|
||||
|
||||
/// Tools that act while the brush is held still (each recorded point is a dab).
|
||||
pub fn is_stationary(self) -> bool {
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft)
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft | LiquifyTool::LassoMask)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +127,8 @@ pub struct LiquifyStroke {
|
||||
pub rate: f64,
|
||||
#[serde(default)]
|
||||
pub points: Vec<Vec<f64>>,
|
||||
/// `reconstructAll` only: how much to restore, 0..100.
|
||||
/// `reconstructAll` only: how much to restore, 0..100. `lassoMask`: 1.0/none freezes the
|
||||
/// polygon, 0.0 thaws it.
|
||||
#[serde(default)]
|
||||
pub amount: Option<f64>,
|
||||
}
|
||||
@@ -216,6 +220,7 @@ impl LiquifyField {
|
||||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
let k = (s.amount.unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
for (v, f) in self.d.iter_mut().zip(&self.freeze) {
|
||||
@@ -242,6 +247,60 @@ impl LiquifyField {
|
||||
dirty
|
||||
}
|
||||
|
||||
/// Lasso: sets the freeze mask to `amount` (1.0 when none; 0.0 thaws) at every field node
|
||||
/// inside the closed polygon in `s.points` (even-odd rule; non-finite points are skipped).
|
||||
/// A scanline fill: each node row costs one pass over the edges, then only the nodes inside
|
||||
/// are written. Returns the document rectangle the mask changed in.
|
||||
fn apply_lasso(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
let poly: Vec<[f64; 2]> = s.points.iter().filter_map(|p| Some([*p.first()?, *p.get(1)?])).filter(|p| p[0].is_finite() && p[1].is_finite()).collect();
|
||||
if poly.len() < 3 || self.w == 0 || self.h == 0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let (mut px0, mut py0, mut px1, mut py1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in &poly {
|
||||
px0 = px0.min(p[0]);
|
||||
py0 = py0.min(p[1]);
|
||||
px1 = px1.max(p[0]);
|
||||
py1 = py1.max(p[1]);
|
||||
}
|
||||
let (_, gy0) = self.to_grid(px0, py0);
|
||||
let (_, gy1) = self.to_grid(px1, py1);
|
||||
let last_row = self.h.saturating_sub(1) as f64;
|
||||
let last_col = self.w.saturating_sub(1) as f64;
|
||||
let (j0, j1) = (gy0.ceil().max(0.0), gy1.floor().min(last_row));
|
||||
if j1 < j0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let v = if s.amount.unwrap_or(1.0) > 0.5 { 1.0f32 } else { 0.0 };
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for j in (j0 as usize)..=(j1 as usize) {
|
||||
let y = self.node_pos(0, j)[1];
|
||||
xs.clear();
|
||||
let Some(mut prev) = poly.last().copied() else { return Rect::EMPTY };
|
||||
for &a in &poly {
|
||||
if (a[1] > y) != (prev[1] > y) {
|
||||
xs.push(a[0] + (y - a[1]) * (prev[0] - a[0]) / (prev[1] - a[1]));
|
||||
}
|
||||
prev = a;
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
// Even-odd: nodes with xs[2k] <= x < xs[2k + 1] are inside.
|
||||
for &[xa, xb] in xs.as_chunks::<2>().0 {
|
||||
let (ga, _) = self.to_grid(xa, y);
|
||||
let (gb, _) = self.to_grid(xb, y);
|
||||
let (i0, i1) = (ga.ceil().max(0.0), (gb.ceil() - 1.0).min(last_col));
|
||||
if i1 < i0 {
|
||||
continue;
|
||||
}
|
||||
let row = j * self.w;
|
||||
if let Some(cells) = self.freeze.get_mut(row + i0 as usize..=row + i1 as usize) {
|
||||
cells.fill(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Rect::new(px0.floor() as i32, py0.floor() as i32, px1.ceil() as i32, py1.ceil() as i32).intersect(&self.bounds)
|
||||
}
|
||||
|
||||
/// Starts a stroke at `p` (`[x, y, pressure]`): stationary tools dab once there. Interactive
|
||||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
@@ -276,6 +335,9 @@ impl LiquifyField {
|
||||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
@@ -699,6 +761,85 @@ mod tests {
|
||||
a.read_region(r).iter().zip(b.read_region(r)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn lasso(points: &[[f64; 2]], amount: Option<f64>) -> LiquifyStroke {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
|
||||
s.points = points.iter().map(|p| p.to_vec()).collect();
|
||||
s.amount = amount;
|
||||
s
|
||||
}
|
||||
|
||||
/// Reference even-odd test (ray casting), to check the scanline fill against.
|
||||
fn inside(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
|
||||
let mut inside = false;
|
||||
let mut j = poly.len() - 1;
|
||||
for (i, a) in poly.iter().enumerate() {
|
||||
let b = poly[j];
|
||||
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_freezes_exactly_the_nodes_inside_a_concave_polygon() {
|
||||
// A concave "C" with a notch, at a non-integer cell size.
|
||||
let poly = [[10.0, 8.0], [70.0, 8.0], [70.0, 20.0], [30.0, 22.5], [33.0, 40.0], [80.0, 41.0], [60.0, 58.0], [9.0, 55.0]];
|
||||
for cell in [1.0, 2.0, 3.5] {
|
||||
let mut f = LiquifyField::new(bounds(), cell);
|
||||
let dirty = f.apply_stroke(&lasso(&poly, None));
|
||||
assert_eq!(dirty, Rect::new(9, 8, 80, 58));
|
||||
for j in 0..f.h {
|
||||
for i in 0..f.w {
|
||||
let want = if inside(f.node_pos(i, j), &poly) { 1.0 } else { 0.0 };
|
||||
assert_eq!(f.freeze[j * f.w + i], want, "cell {cell}: node ({i}, {j}) at {:?}", f.node_pos(i, j));
|
||||
}
|
||||
}
|
||||
assert!(f.freeze.contains(&1.0));
|
||||
// Strokes replay to the same field (undo rebuilds from the list).
|
||||
assert_eq!(LiquifyField::from_strokes(bounds(), cell, &[lasso(&poly, None)]), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_thaws_with_amount_zero() {
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.apply_stroke(&LiquifyStroke::new(LiquifyTool::FreezeAll, 1.0));
|
||||
assert!(f.freeze.iter().all(|&v| v == 1.0));
|
||||
let square = [[20.0, 20.0], [40.0, 20.0], [40.0, 40.0], [20.0, 40.0]];
|
||||
f.apply_stroke(&lasso(&square, Some(0.0)));
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 0.0, "thawed inside");
|
||||
assert_eq!(f.freeze_at(5.0, 5.0), 1.0, "still frozen outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_ignores_degenerate_and_hostile_polygons() {
|
||||
let empty = LiquifyField::new(bounds(), 2.0);
|
||||
let cases: [&[[f64; 2]]; 6] = [
|
||||
&[],
|
||||
&[[10.0, 10.0], [50.0, 50.0]],
|
||||
&[[f64::NAN, 10.0], [50.0, f64::INFINITY], [20.0, 30.0]],
|
||||
// Entirely outside the canvas, on every side.
|
||||
&[[-50.0, -50.0], [-10.0, -50.0], [-10.0, -10.0]],
|
||||
&[[200.0, 10.0], [300.0, 10.0], [250.0, 50.0]],
|
||||
&[[10.0, 1e12], [50.0, 1e12], [30.0, 2e12]],
|
||||
];
|
||||
for poly in cases {
|
||||
let mut f = empty.clone();
|
||||
assert_eq!(f.apply_stroke(&lasso(poly, None)), Rect::EMPTY, "{poly:?}");
|
||||
assert_eq!(f, empty, "{poly:?}");
|
||||
}
|
||||
// Huge coordinates around the canvas clamp to it instead of overflowing.
|
||||
let mut f = empty.clone();
|
||||
f.apply_stroke(&lasso(&[[-1e15, -1e15], [1e15, -1e15], [1e15, 1e15], [-1e15, 1e15]], None));
|
||||
assert!(f.freeze.iter().all(|&v| v == 1.0));
|
||||
// A NaN point among good ones is skipped.
|
||||
let mut f = empty.clone();
|
||||
f.apply_stroke(&lasso(&[[10.0, 10.0], [f64::NAN, 0.0], [50.0, 10.0], [50.0, 50.0], [10.0, 50.0]], None));
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_strokes_is_identity_at_every_depth() {
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
+71
-22
@@ -76,39 +76,41 @@ pub(crate) fn running_extreme(v: &[f32], k: usize, max: bool, out: &mut Vec<f32>
|
||||
out.extend((0..=n - k).map(|i| pick(suf[i], pre[i + k - 1])));
|
||||
}
|
||||
|
||||
/// Minimum (spreads dark/transparent areas) or Maximum over a square.
|
||||
/// Minimum (spreads dark/transparent areas) or Maximum over a square. Separable (rows, then
|
||||
/// columns), each pass a [`running_extreme`], so the cost per pixel does not grow with the radius.
|
||||
pub(crate) fn min_max(src: &Image, out: Rect, radius: f32, max: bool) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let r = radius.max(0.0).round() as i32;
|
||||
if r == 0 {
|
||||
return src.crop(out);
|
||||
}
|
||||
// Separable: rows then columns.
|
||||
let k = (2 * r + 1) as usize;
|
||||
// Rows: the extreme over x - r..=x + r, for every row the column pass needs.
|
||||
let win = Rect::new(out.x0, out.y0 - r, out.x1, out.y1 + r);
|
||||
let ww = win.width() as usize;
|
||||
let mut tmp = vec![0.0f32; ww * win.height() as usize * n];
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
let mut tmp = vec![0.0f32; ww * wh * n];
|
||||
let (mut line, mut ext) = (Vec::new(), Vec::new());
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
for c in 0..n {
|
||||
let mut m = if max { f32::MIN } else { f32::MAX };
|
||||
for xx in x - r..=x + r {
|
||||
let v = src.get(xx, y, c);
|
||||
m = if max { m.max(v) } else { m.min(v) };
|
||||
}
|
||||
tmp[((y - win.y0) as usize * ww + (x - win.x0) as usize) * n + c] = m;
|
||||
let o = (y - win.y0) as usize * ww * n;
|
||||
for c in 0..n {
|
||||
line.clear();
|
||||
line.extend((win.x0 - r..win.x1 + r).map(|x| src.get(x, y, c)));
|
||||
running_extreme(&line, k, max, &mut ext);
|
||||
for (x, v) in ext.iter().enumerate() {
|
||||
tmp[o + x * n + c] = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
|
||||
for y in out.y0..out.y1 {
|
||||
for x in out.x0..out.x1 {
|
||||
for c in 0..n {
|
||||
let mut m = if max { f32::MIN } else { f32::MAX };
|
||||
for yy in y - r..=y + r {
|
||||
let v = tmp[((yy - win.y0) as usize * ww + (x - win.x0) as usize) * n + c];
|
||||
m = if max { m.max(v) } else { m.min(v) };
|
||||
}
|
||||
res.push(m);
|
||||
// Columns: the extreme over y - r..=y + r of the row results.
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
for x in 0..ow {
|
||||
for c in 0..n {
|
||||
line.clear();
|
||||
line.extend((0..wh).map(|y| tmp[(y * ww + x) * n + c]));
|
||||
running_extreme(&line, k, max, &mut ext);
|
||||
for (y, v) in ext.iter().enumerate() {
|
||||
res[(y * ow + x) * n + c] = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,3 +139,50 @@ pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefi
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The square window scanned directly, the reference for [`min_max`].
|
||||
fn min_max_direct(src: &Image, out: Rect, r: i32, max: bool) -> Vec<f32> {
|
||||
let mut res = Vec::new();
|
||||
for y in out.y0..out.y1 {
|
||||
for x in out.x0..out.x1 {
|
||||
for c in 0..src.ch {
|
||||
let mut m = if max { f32::MIN } else { f32::MAX };
|
||||
for yy in y - r..=y + r {
|
||||
for xx in x - r..=x + r {
|
||||
let v = src.get(xx, yy, c);
|
||||
m = if max { m.max(v) } else { m.min(v) };
|
||||
}
|
||||
}
|
||||
res.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_min_max_matches_the_direct_window_exactly() {
|
||||
let src_rect = Rect::new(-12, -9, 37, 30);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x2545_f491u32;
|
||||
for v in img.data.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 1000) as f32 / 999.0;
|
||||
}
|
||||
// Includes windows wider than the output and output reaching past the source.
|
||||
for out in [Rect::new(0, 0, 25, 21), Rect::new(-14, -3, 3, 40)] {
|
||||
for r in [1, 2, 3, 7, 20] {
|
||||
for max in [false, true] {
|
||||
let fast = min_max(&img, out, r as f32, max);
|
||||
assert_eq!(fast, min_max_direct(&img, out, r, max), "r {r} max {max} out {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,13 @@ const FINE_OMEGA: f32 = 1.7;
|
||||
pub fn solve_membrane(w: usize, h: usize, unknown: &[bool], v: &mut [f32]) {
|
||||
assert_eq!(unknown.len(), w * h);
|
||||
assert_eq!(v.len(), w * h);
|
||||
solve_rec(w, h, unknown, v, 0);
|
||||
solve_rec(w, h, unknown, v, 0, &|| false);
|
||||
}
|
||||
|
||||
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize) {
|
||||
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize, stop: &(dyn Fn() -> bool + Sync)) {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
let n_unknown = unknown.iter().filter(|u| **u).count();
|
||||
if n_unknown == 0 {
|
||||
return;
|
||||
@@ -64,7 +67,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
solve_rec(cw, chh, &cu, &mut cv, depth + 1);
|
||||
solve_rec(cw, chh, &cu, &mut cv, depth + 1, stop);
|
||||
// Prolong: bilinear sample of the coarse solution as the initial guess.
|
||||
for y in 0..h {
|
||||
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (chh - 1) as f32);
|
||||
@@ -84,7 +87,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
|
||||
v[y * w + x] = a + (b - a) * ty;
|
||||
}
|
||||
}
|
||||
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA);
|
||||
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA, stop);
|
||||
} else {
|
||||
let mean = known.iter().sum::<f32>() / known.len() as f32;
|
||||
for (x, u) in v.iter_mut().zip(unknown) {
|
||||
@@ -92,12 +95,12 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
|
||||
*x = mean;
|
||||
}
|
||||
}
|
||||
sor(w, h, unknown, v, 2000, 1.85);
|
||||
sor(w, h, unknown, v, 2000, 1.85, stop);
|
||||
}
|
||||
}
|
||||
|
||||
/// SOR sweeps over the unknown pixels until the largest update drops below [`TOL`].
|
||||
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32) {
|
||||
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32, stop: &(dyn Fn() -> bool + Sync)) {
|
||||
// Precompute the unknown pixels and which neighbours exist.
|
||||
let cells: Vec<(u32, u8)> = (0..w * h)
|
||||
.filter(|&i| unknown[i])
|
||||
@@ -108,6 +111,9 @@ fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, ome
|
||||
})
|
||||
.collect();
|
||||
for _ in 0..max_iter {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
let mut max_d = 0.0f32;
|
||||
for &(i, m) in &cells {
|
||||
let i = i as usize;
|
||||
@@ -229,6 +235,28 @@ pub fn membrane_fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
|
||||
})
|
||||
}
|
||||
|
||||
/// [`membrane_fill`] that stops at the next SOR sweep once `ctl` is cancelled.
|
||||
pub fn membrane_fill_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
hole: &[bool],
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
|
||||
ctl.check()?;
|
||||
let stop = || ctl.cancelled();
|
||||
let out = per_channel(w, h, ch, |c| {
|
||||
let mut v: Vec<f32> = (0..w * h).map(|i| if hole[i] { 0.0 } else { img[i * ch + c] }).collect();
|
||||
if hole.len() == w * h && v.len() == w * h {
|
||||
solve_rec(w, h, hole, &mut v, 0, &stop);
|
||||
}
|
||||
v
|
||||
});
|
||||
ctl.check()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Photographic relight: recover shading with a blur (Retinex) and replace it with a
|
||||
//! Lambertian lamp whose direction comes from angle / elevation.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::fxutil::{MAXC, rgba, set_rgba, xy};
|
||||
use crate::image::Image;
|
||||
|
||||
/// Neighbourhood the kernel reads: `3σ + 1` at `σ = 32` (the typical cap for parallel tiles).
|
||||
pub(crate) const HALO_RADIUS: i32 = 97;
|
||||
|
||||
/// Dialog units (degrees / 0–100), matching the command params.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Params {
|
||||
pub angle: f32,
|
||||
pub elevation: f32,
|
||||
pub intensity: f32,
|
||||
pub ambient: f32,
|
||||
pub warmth: f32,
|
||||
pub softness: f32,
|
||||
}
|
||||
|
||||
/// Blur σ from softness and the reference bounds, clamped to `[2, 96]`.
|
||||
pub(crate) fn sigma(softness: f32, bounds: Rect) -> f32 {
|
||||
let side = bounds.width().min(bounds.height()) as f32;
|
||||
let s = (softness.clamp(1.0, 100.0) / 100.0) * 0.08 * side.max(1.0);
|
||||
s.clamp(2.0, 96.0)
|
||||
}
|
||||
|
||||
/// Infinite-light direction: 0° from the right, 90° from above (image space, y down).
|
||||
fn light_dir(angle: f32, elevation: f32) -> [f32; 3] {
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let e = elevation.clamp(0.0, 90.0).to_radians();
|
||||
norm3([co * e.cos(), -s * e.cos(), e.sin()])
|
||||
}
|
||||
|
||||
fn norm3(v: [f32; 3]) -> [f32; 3] {
|
||||
let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if !m.is_finite() || m < 1e-9 { [0.0, 0.0, 1.0] } else { [v[0] / m, v[1] / m, v[2] / m] }
|
||||
}
|
||||
|
||||
fn light_color(warmth: f32) -> [f32; 3] {
|
||||
let w = warmth.clamp(-100.0, 100.0) / 100.0;
|
||||
if w >= 0.0 {
|
||||
[1.0 + 0.15 * w, 1.0 - 0.05 * w, 1.0 - 0.25 * w]
|
||||
} else {
|
||||
let t = -w;
|
||||
[1.0 - 0.20 * t, 1.0 - 0.10 * t, 1.0 + 0.15 * t]
|
||||
}
|
||||
}
|
||||
|
||||
fn rec709_y(c: [f32; 4]) -> f32 {
|
||||
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
}
|
||||
|
||||
fn finite_all(v: &[f32]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
}
|
||||
|
||||
fn sample_y(buf: &[f32], w: usize, h: usize, rect: Rect, x: i32, y: i32) -> f32 {
|
||||
if rect.is_empty() || w == 0 || h == 0 {
|
||||
return 0.04;
|
||||
}
|
||||
let xx = x.clamp(rect.x0, rect.x1.saturating_sub(1));
|
||||
let yy = y.clamp(rect.y0, rect.y1.saturating_sub(1));
|
||||
let ox = (xx - rect.x0) as usize;
|
||||
let oy = (yy - rect.y0) as usize;
|
||||
if ox >= w || oy >= h {
|
||||
return 0.04;
|
||||
}
|
||||
buf.get(oy.saturating_mul(w).saturating_add(ox)).copied().filter(|v| v.is_finite()).unwrap_or(0.04)
|
||||
}
|
||||
|
||||
/// Separable Gaussian of a single-channel field. Non-finite samples are skipped (kernel
|
||||
/// renormalised) so a bad pixel does not poison its neighbours.
|
||||
fn blur_channel(src: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
if n == 0 || src.len() < n {
|
||||
return vec![0.04; n];
|
||||
}
|
||||
let k = crate::blur::gaussian_kernel(sigma);
|
||||
let r = (k.len() / 2) as i32;
|
||||
let acc_at = |buf: &[f32], ww: usize, hh: usize, x: i32, y: i32, col: bool| -> f32 {
|
||||
let mut acc = 0.0f64;
|
||||
let mut wgt = 0.0f64;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let t = i as i32 - r;
|
||||
let (sx, sy) = if col { (x, y + t) } else { (x + t, y) };
|
||||
if sx < 0 || sy < 0 || (sx as usize) >= ww || (sy as usize) >= hh {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = buf.get((sy as usize).saturating_mul(ww).saturating_add(sx as usize)) else {
|
||||
continue;
|
||||
};
|
||||
if !v.is_finite() {
|
||||
continue;
|
||||
}
|
||||
acc += *v as f64 * *kv as f64;
|
||||
wgt += *kv as f64;
|
||||
}
|
||||
if wgt > 1e-12 { (acc / wgt) as f32 } else { 0.5 }
|
||||
};
|
||||
let mut tmp = vec![0.0f32; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if let Some(slot) = tmp.get_mut(y.saturating_mul(w).saturating_add(x)) {
|
||||
*slot = acc_at(src, w, h, x as i32, y as i32, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if let Some(slot) = out.get_mut(y.saturating_mul(w).saturating_add(x)) {
|
||||
*slot = acc_at(&tmp, w, h, x as i32, y as i32, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_rgba(src: &Image, ctx: &Ctx, x: i32, y: i32) -> [f32; 4] {
|
||||
let n = src.ch.min(MAXC);
|
||||
let mut tmp = [0.0f32; MAXC];
|
||||
for c in 0..n {
|
||||
if let Some(slot) = tmp.get_mut(c) {
|
||||
*slot = src.get(x, y, c);
|
||||
}
|
||||
}
|
||||
rgba(ctx, &tmp[..n])
|
||||
}
|
||||
|
||||
/// Relight `out` of `src`. Identity when `intensity` is 0 (or not finite).
|
||||
pub(crate) fn relight(src: &Image, out: Rect, ctx: &Ctx, p: Params) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
if out.is_empty() || n == 0 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let mut res = src.crop(out);
|
||||
if !p.intensity.is_finite() || p.intensity == 0.0 {
|
||||
return res;
|
||||
}
|
||||
let sig = sigma(p.softness, ctx.bounds);
|
||||
let radius = (sig * 3.0).ceil() as i32;
|
||||
// Finite differences at the shading scale (`σ`), not 1 px: a 1 px step vanishes on
|
||||
// large documents and the lamp collapses to a flat multiply.
|
||||
const SCALE: f32 = 2.0;
|
||||
let step = sig.round().clamp(1.0, 32.0) as i32;
|
||||
let s_rect = out.inflate(step.max(1));
|
||||
let y_rect = s_rect.inflate(radius.max(0));
|
||||
if y_rect.is_empty() {
|
||||
return res;
|
||||
}
|
||||
let yw = y_rect.width() as usize;
|
||||
let yh = y_rect.height() as usize;
|
||||
let n_px = yw.saturating_mul(yh);
|
||||
if n_px == 0 || n_px > 8_000_000 {
|
||||
return res;
|
||||
}
|
||||
let mut ybuf = vec![0.5f32; n_px];
|
||||
for y in y_rect.y0..y_rect.y1 {
|
||||
for x in y_rect.x0..y_rect.x1 {
|
||||
let ox = (x - y_rect.x0) as usize;
|
||||
let oy = (y - y_rect.y0) as usize;
|
||||
let Some(slot) = ybuf.get_mut(oy.saturating_mul(yw).saturating_add(ox)) else {
|
||||
continue;
|
||||
};
|
||||
let c = read_rgba(src, ctx, x, y);
|
||||
if !finite_all(&c) {
|
||||
*slot = f32::NAN;
|
||||
continue;
|
||||
}
|
||||
let a = if ctx.alpha { c[3] } else { 1.0 };
|
||||
*slot = if a > 0.0 && a.is_finite() { rec709_y(c) } else { f32::NAN };
|
||||
}
|
||||
}
|
||||
let sbuf = blur_channel(&ybuf, yw, yh, sig);
|
||||
let ldir = light_dir(p.angle, p.elevation);
|
||||
let lcol = light_color(p.warmth);
|
||||
let amb = (if p.ambient.is_finite() { p.ambient } else { 0.0 }).clamp(0.0, 100.0) / 100.0;
|
||||
let inten = p.intensity.clamp(0.0, 100.0) / 100.0;
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
if !finite_all(px) {
|
||||
continue;
|
||||
}
|
||||
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
|
||||
if ctx.alpha && a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = rgba(ctx, px);
|
||||
if !finite_all(&c) {
|
||||
continue;
|
||||
}
|
||||
let s0 = sample_y(&sbuf, yw, yh, y_rect, x, y).max(0.04);
|
||||
let nx = (sample_y(&sbuf, yw, yh, y_rect, x - step, y) - sample_y(&sbuf, yw, yh, y_rect, x + step, y)) * SCALE;
|
||||
let ny = (sample_y(&sbuf, yw, yh, y_rect, x, y - step) - sample_y(&sbuf, yw, yh, y_rect, x, y + step)) * SCALE;
|
||||
let nrm = norm3([nx, ny, 1.0]);
|
||||
let ndl = (nrm[0] * ldir[0] + nrm[1] * ldir[1] + nrm[2] * ldir[2]).max(0.0);
|
||||
let shade = amb + inten * ndl;
|
||||
let mut out_c = c;
|
||||
for (k, lc) in lcol.iter().enumerate() {
|
||||
let Some(ch) = c.get(k).copied() else { continue };
|
||||
let Some(slot) = out_c.get_mut(k) else { continue };
|
||||
*slot = (ch / s0) * shade * *lc;
|
||||
}
|
||||
if !finite_all(&out_c) {
|
||||
continue;
|
||||
}
|
||||
set_rgba(ctx, px, out_c);
|
||||
if ctx.alpha
|
||||
&& let Some(slot) = px.get_mut(n - 1)
|
||||
{
|
||||
*slot = a;
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{FilterParams, Halo, apply, apply_tiled, output_area};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn fmt(s: SampleType) -> PixelFormat {
|
||||
PixelFormat::new(ColorMode::Rgb, s, true)
|
||||
}
|
||||
|
||||
fn gray_fmt(s: SampleType) -> PixelFormat {
|
||||
PixelFormat::new(ColorMode::Grayscale, s, true)
|
||||
}
|
||||
|
||||
fn params(intensity: f32, angle: f32, warmth: f32, softness: f32) -> FilterParams {
|
||||
FilterParams::Relight { angle, elevation: 40.0, intensity, ambient: 55.0, warmth, softness }
|
||||
}
|
||||
|
||||
fn relight_p(angle: f32, elevation: f32, intensity: f32, ambient: f32, warmth: f32, softness: f32) -> FilterParams {
|
||||
FilterParams::Relight { angle, elevation, intensity, ambient, warmth, softness }
|
||||
}
|
||||
|
||||
fn run(s: &Surface, p: &FilterParams, bounds: Rect) -> Surface {
|
||||
let area = output_area(p, s.content_bounds(), bounds, None);
|
||||
apply(s, p, area, bounds, None)
|
||||
}
|
||||
|
||||
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn luma(px: &[f32]) -> f32 {
|
||||
0.2126 * px[0] + 0.7152 * px[1] + 0.0722 * px[2]
|
||||
}
|
||||
|
||||
fn shaded_sphere(st: SampleType, size: i32) -> Surface {
|
||||
let r = Rect::new(0, 0, size, size);
|
||||
let mut surf = Surface::new(fmt(st));
|
||||
let cx = size as f32 / 2.0;
|
||||
let rad = size as f32 * 0.42;
|
||||
let mut v = Vec::new();
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
let dy = y as f32 + 0.5 - cx;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 <= rad * rad {
|
||||
let nz = (rad * rad - d2).max(0.0).sqrt() / rad;
|
||||
let sh = 0.22 + 0.78 * nz;
|
||||
v.extend_from_slice(&[0.55 * sh, 0.55 * sh, 0.55 * sh, 1.0]);
|
||||
} else {
|
||||
v.extend_from_slice(&[0.18, 0.18, 0.18, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
surf.write_region(r, &v);
|
||||
surf
|
||||
}
|
||||
|
||||
fn sphere_half_mean(s: &Surface, size: i32, left: bool) -> f32 {
|
||||
let cx = size as f32 / 2.0;
|
||||
let rad = size as f32 * 0.42;
|
||||
let mut sum = 0.0;
|
||||
let mut n: f32 = 0.0;
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
let dy = y as f32 + 0.5 - cx;
|
||||
if dx * dx + dy * dy > rad * rad {
|
||||
continue;
|
||||
}
|
||||
if left && (x as f32) >= cx - 4.0 {
|
||||
continue;
|
||||
}
|
||||
if !left && (x as f32) < cx + 4.0 {
|
||||
continue;
|
||||
}
|
||||
sum += luma(&s.pixel(x, y));
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
sum / n.max(1.0)
|
||||
}
|
||||
|
||||
fn pattern(st: SampleType, r: Rect) -> Surface {
|
||||
let mut surf = Surface::new(fmt(st));
|
||||
let mut v = Vec::new();
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
v.extend_from_slice(&[((x * 7 + y * 3) % 64) as f32 / 63.0, ((x * x + y) % 50) as f32 / 49.0, (y % 9) as f32 / 8.0, 1.0]);
|
||||
}
|
||||
}
|
||||
surf.write_region(r, &v);
|
||||
surf
|
||||
}
|
||||
|
||||
fn flat(st: SampleType, r: Rect, px: [f32; 4]) -> Surface {
|
||||
let mut surf = Surface::new(fmt(st));
|
||||
surf.fill_rect(r, &px);
|
||||
surf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halo_is_zero_at_identity_and_97_otherwise() {
|
||||
assert_eq!(params(0.0, 45.0, 0.0, 25.0).halo(), Halo::Radius(0));
|
||||
assert_eq!(params(40.0, 45.0, 0.0, 25.0).halo(), Halo::Radius(HALO_RADIUS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_at_zero_intensity_all_depths() {
|
||||
let r = Rect::new(0, 0, 24, 16);
|
||||
let p = params(0.0, 120.0, 80.0, 90.0);
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let s = pattern(st, r);
|
||||
let out = run(&s, &p, r);
|
||||
assert_eq!(out.read_region(r), s.read_region(r), "{st:?}");
|
||||
}
|
||||
let mut g = Surface::new(gray_fmt(SampleType::U8));
|
||||
g.fill_rect(r, &[0.4, 1.0]);
|
||||
let out = run(&g, &p, r);
|
||||
assert_eq!(out.read_region(r), g.read_region(r));
|
||||
let mut g32 = Surface::new(gray_fmt(SampleType::F32));
|
||||
g32.fill_rect(r, &[0.4, 1.0]);
|
||||
let out = run(&g32, &p, r);
|
||||
assert_eq!(out.read_region(r), g32.read_region(r));
|
||||
let mut g16 = Surface::new(gray_fmt(SampleType::U16));
|
||||
g16.fill_rect(r, &[0.4, 1.0]);
|
||||
let out = run(&g16, &p, r);
|
||||
assert_eq!(out.read_region(r), g16.read_region(r));
|
||||
}
|
||||
|
||||
fn assert_direction_flips(size: i32) {
|
||||
let bounds = Rect::new(0, 0, size, size);
|
||||
let s = shaded_sphere(SampleType::F32, size);
|
||||
let left_lit = run(&s, &relight_p(180.0, 30.0, 90.0, 12.0, 0.0, 15.0), bounds);
|
||||
let right_lit = run(&s, &relight_p(0.0, 30.0, 90.0, 12.0, 0.0, 15.0), bounds);
|
||||
let l_left = sphere_half_mean(&left_lit, size, true);
|
||||
let l_right = sphere_half_mean(&left_lit, size, false);
|
||||
let r_left = sphere_half_mean(&right_lit, size, true);
|
||||
let r_right = sphere_half_mean(&right_lit, size, false);
|
||||
assert!(l_left - l_right >= 0.05, "light from the left ({size}px): {l_left} vs {l_right}");
|
||||
assert!(r_right - r_left >= 0.05, "light from the right ({size}px): {r_left} vs {r_right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_flips_on_a_shaded_sphere() {
|
||||
assert_direction_flips(64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_flips_on_a_large_shaded_sphere() {
|
||||
// A 1 px gradient vanishes at this size unless the kernel steps by σ.
|
||||
assert_direction_flips(256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmth_tints_a_neutral_field() {
|
||||
let r = Rect::new(0, 0, 32, 32);
|
||||
let s = flat(SampleType::F32, r, [0.5, 0.5, 0.5, 1.0]);
|
||||
let warm = run(&s, &relight_p(45.0, 40.0, 50.0, 55.0, 80.0, 25.0), r);
|
||||
let cool = run(&s, &relight_p(45.0, 40.0, 50.0, 55.0, -80.0, 25.0), r);
|
||||
let w = warm.pixel(16, 16);
|
||||
let c = cool.pixel(16, 16);
|
||||
assert!(w[0] > w[2], "warmth should raise R over B: {w:?}");
|
||||
assert!(c[2] > c[0], "cool light should raise B over R: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_ambient_keeps_white_finite() {
|
||||
let r = Rect::new(0, 0, 16, 16);
|
||||
let s = flat(SampleType::F32, r, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run(&s, &relight_p(45.0, 40.0, 40.0, 100.0, 0.0, 25.0), r);
|
||||
let px = out.pixel(8, 8);
|
||||
assert!(finite_all(&px), "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_is_preserved() {
|
||||
let r = Rect::new(0, 0, 16, 16);
|
||||
let mut s = Surface::new(fmt(SampleType::F32));
|
||||
s.fill_rect(r, &[0.4, 0.4, 0.5, 1.0]);
|
||||
s.write_pixel(3, 3, &[0.4, 0.4, 0.5, 0.0]);
|
||||
s.write_pixel(4, 4, &[0.2, 0.3, 0.4, 0.5]);
|
||||
let out = run(&s, ¶ms(50.0, 180.0, 20.0, 25.0), r);
|
||||
assert_eq!(out.pixel(3, 3)[3], 0.0);
|
||||
assert!((out.pixel(5, 5)[3] - 1.0).abs() < 1e-6);
|
||||
assert!((out.pixel(4, 4)[3] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_match_a_full_apply() {
|
||||
let size = 64;
|
||||
let bounds = Rect::new(0, 0, size, size);
|
||||
let s = shaded_sphere(SampleType::F32, size);
|
||||
let p = relight_p(180.0, 40.0, 60.0, 40.0, 0.0, 25.0);
|
||||
let area = output_area(&p, s.content_bounds(), bounds, None);
|
||||
let full = apply_tiled(&s, &p, area, bounds, None, 256, None);
|
||||
let tiled = apply_tiled(&s, &p, area, bounds, None, 32, None);
|
||||
let inner = Rect::new(0, 0, size, size);
|
||||
let d = max_diff(&full.read_region(inner), &tiled.read_region(inner));
|
||||
assert!(d <= 1e-4, "tile seam {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_do_not_panic() {
|
||||
let p = params(40.0, 45.0, 0.0, 25.0);
|
||||
for (w, h) in [(1, 1), (2, 2), (1, 2), (0, 0)] {
|
||||
let r = Rect::new(0, 0, w, h);
|
||||
let mut s = Surface::new(fmt(SampleType::F32));
|
||||
if w > 0 && h > 0 {
|
||||
s.fill_rect(r, &[0.4, 0.5, 0.6, 1.0]);
|
||||
}
|
||||
let _ = run(&s, &p, if r.is_empty() { Rect::new(0, 0, 1, 1) } else { r });
|
||||
let _ = apply(&s, &p, Rect::EMPTY, Rect::new(0, 0, 4, 4), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_and_inf_pixels_pass_through() {
|
||||
let r = Rect::new(0, 0, 24, 24);
|
||||
let mut s = pattern(SampleType::F32, r);
|
||||
s.write_pixel(5, 5, &[f32::NAN, 0.4, 0.4, 1.0]);
|
||||
s.write_pixel(6, 8, &[f32::INFINITY, 0.2, 0.2, 1.0]);
|
||||
let before_n = s.pixel(7, 5);
|
||||
let out = run(&s, ¶ms(50.0, 180.0, 0.0, 20.0), r);
|
||||
assert!(out.pixel(5, 5)[0].is_nan());
|
||||
assert!(out.pixel(6, 8)[0].is_infinite());
|
||||
let n = out.pixel(7, 5);
|
||||
assert!(finite_all(&n));
|
||||
assert_ne!(n, before_n);
|
||||
}
|
||||
}
|
||||
@@ -71,15 +71,17 @@ struct Taps {
|
||||
}
|
||||
|
||||
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
|
||||
/// via `u = (o + 0.5) / scale - 0.5`.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
|
||||
/// 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.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>) -> Vec<Taps> {
|
||||
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
|
||||
let support = filter.support() * stretch;
|
||||
let clamp = |i: i32| edge.map_or(i, |(a, b)| i.clamp(a, b));
|
||||
(dst0..dst1)
|
||||
.map(|o| {
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
if filter == Resample::Nearest && scale >= 1.0 {
|
||||
return Taps { start: (u + 0.5).floor() as i32, weights: vec![1.0] };
|
||||
return Taps { start: clamp((u + 0.5).floor() as i32), weights: vec![1.0] };
|
||||
}
|
||||
let lo = (u - support).ceil() as i32;
|
||||
let hi = (u + support).floor() as i32;
|
||||
@@ -90,7 +92,15 @@ fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
|
||||
*v /= s;
|
||||
}
|
||||
}
|
||||
Taps { start: lo, weights: w.into_iter().map(|v| v as f32).collect() }
|
||||
// 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];
|
||||
for (i, v) in (lo..=hi).zip(w) {
|
||||
if let Some(slot) = folded.get_mut((clamp(i) - start) as usize) {
|
||||
*slot += v as f32;
|
||||
}
|
||||
}
|
||||
Taps { start, weights: folded }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -106,6 +116,19 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
|
||||
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
|
||||
/// default pixel is kept (masks keep their "reveal all" background).
|
||||
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
|
||||
resize(s, sx, sy, filter, None)
|
||||
}
|
||||
|
||||
/// [`resize_surface`] for a layer, mask or channel of a document whose canvas is `canvas`
|
||||
/// (Image Size): at the canvas edges the pixels repeat outwards, so content that reaches an edge
|
||||
/// 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))
|
||||
}
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
let fmt = s.format();
|
||||
let mut out = Surface::with_default(fmt, &s.default_pixel());
|
||||
let src = s.content_bounds();
|
||||
@@ -115,8 +138,8 @@ pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surfac
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let alpha = fmt.alpha;
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter);
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)));
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)));
|
||||
let x_lo = hx.iter().map(|t| t.start).min().unwrap_or(0);
|
||||
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(0);
|
||||
let dw = dst.width() as usize;
|
||||
@@ -268,6 +291,38 @@ mod tests {
|
||||
assert!(edge[3] > 0.0 && (edge[0] - 1.0).abs() < 0.05 && edge[1].abs() < 0.05, "{edge:?}");
|
||||
}
|
||||
|
||||
/// Image Size of a layer that fills the canvas: its edges stay opaque and keep their colour,
|
||||
/// up or down, at every depth; a layer away from the edges keeps its soft border.
|
||||
#[test]
|
||||
fn canvas_edges_repeat_instead_of_fading() {
|
||||
let canvas = Rect::new(0, 0, 30, 20);
|
||||
let colour = [0.2, 0.6, 1.0, 1.0];
|
||||
let filters = [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos, Resample::PreserveDetails];
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(canvas, &colour);
|
||||
for f in filters {
|
||||
for k in [2.0, 1.37, 0.5] {
|
||||
let o = resize_surface_in_canvas(&s, k, k, f, canvas);
|
||||
let dst = scaled_rect(canvas, k, k);
|
||||
assert_eq!(o.content_bounds(), dst, "{sample:?} {f:?} {k}");
|
||||
for (x, y) in [(0, 0), (dst.x1 - 1, 0), (0, dst.y1 - 1), (dst.x1 - 1, dst.y1 - 1), (dst.x1 / 2, 0), (0, dst.y1 / 2)] {
|
||||
let p = o.pixel(x, y);
|
||||
let off = p.iter().zip(colour).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(off < 0.01, "{sample:?} {f:?} {k} ({x},{y}): {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside the canvas the default pixel is still what lies around a layer.
|
||||
let mut inner = Surface::new(fmt);
|
||||
inner.fill_rect(Rect::new(10, 5, 20, 15), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let r = Rect::new(0, 0, 60, 40);
|
||||
let a = resize_surface_in_canvas(&inner, 1.7, 1.7, Resample::Lanczos, canvas);
|
||||
assert_eq!(a.read_region(r), resize_surface(&inner, 1.7, 1.7, Resample::Lanczos).read_region(r), "{sample:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_keep_default_pixel() {
|
||||
let fmt = PixelFormat::GRAY8;
|
||||
|
||||
+516
-87
@@ -11,6 +11,8 @@
|
||||
//! seams avoid protected pixels. Buffers are interleaved `w × h × ch` normalised floats of any
|
||||
//! colour model and depth (alpha is carried like any other channel). Deterministic.
|
||||
|
||||
use photocraft_raster::{Cancelled, Interrupt};
|
||||
|
||||
/// Energy added per unit of protection: far above any gradient magnitude.
|
||||
const PROTECT_ENERGY: f32 = 1.0e4;
|
||||
|
||||
@@ -19,69 +21,206 @@ const PROTECT_ENERGY: f32 = 1.0e4;
|
||||
/// carry energy too.
|
||||
pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
let mut e = vec![0.0f32; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1));
|
||||
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
let at = |xx: usize, yy: usize, c: usize| img[(yy * w + xx) * ch + c];
|
||||
let mut s = 0.0;
|
||||
for c in 0..ch {
|
||||
let v = at(x, y, c);
|
||||
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
|
||||
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
|
||||
}
|
||||
e[y * w + x] = s * 0.5;
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return e;
|
||||
}
|
||||
let row = |(y, out): (usize, &mut [f32])| {
|
||||
for (x, v) in out.iter_mut().enumerate() {
|
||||
*v = energy_at(img, w, w, h, ch, x, y);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
e.par_chunks_mut(w).enumerate().for_each(row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
e.chunks_mut(w).enumerate().for_each(row);
|
||||
e
|
||||
}
|
||||
|
||||
/// [`energy`] of one pixel of a `cw`-wide image whose rows are `stride` pixels apart. Shared by
|
||||
/// the full map and the per-seam updates so both give bit-identical values.
|
||||
#[inline]
|
||||
fn energy_at(img: &[f32], stride: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize) -> f32 {
|
||||
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(cw - 1));
|
||||
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
let at = |xx: usize, yy: usize, c: usize| img[(yy * stride + xx) * ch + c];
|
||||
let mut s = 0.0;
|
||||
for c in 0..ch {
|
||||
let v = at(x, y, c);
|
||||
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
|
||||
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
|
||||
}
|
||||
s * 0.5
|
||||
}
|
||||
|
||||
/// The lowest-energy vertical seam: one x per row.
|
||||
pub fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec<usize> {
|
||||
let mut cost = energy[..w].to_vec();
|
||||
let mut from = vec![0u8; w * h]; // 0 = up-left, 1 = up, 2 = up-right
|
||||
let mut next = vec![0.0f32; w];
|
||||
for y in 1..h {
|
||||
for x in 0..w {
|
||||
let mut best = (cost[x], 1u8);
|
||||
if x > 0 && cost[x - 1] < best.0 {
|
||||
best = (cost[x - 1], 0);
|
||||
}
|
||||
if x + 1 < w && cost[x + 1] < best.0 {
|
||||
best = (cost[x + 1], 2);
|
||||
}
|
||||
next[x] = best.0 + energy[y * w + x];
|
||||
from[y * w + x] = best.1;
|
||||
}
|
||||
std::mem::swap(&mut cost, &mut next);
|
||||
if w == 0 || h == 0 || energy.len() < w * h {
|
||||
return vec![0; h];
|
||||
}
|
||||
let mut x = (0..w).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
|
||||
let mut seam = vec![0usize; h];
|
||||
for y in (0..h).rev() {
|
||||
seam[y] = x;
|
||||
if y > 0 {
|
||||
x = match from[y * w + x] {
|
||||
0 => x - 1,
|
||||
2 => x + 1,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
seam
|
||||
Dp::default().seam(w, w, h, energy)
|
||||
}
|
||||
|
||||
fn remove_seam(w: usize, h: usize, ch: usize, img: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity((w - 1) * h * ch);
|
||||
for (y, &sx) in seam.iter().enumerate().take(h) {
|
||||
let row = &img[y * w * ch..(y + 1) * w * ch];
|
||||
out.extend_from_slice(&row[..sx * ch]);
|
||||
out.extend_from_slice(&row[(sx + 1) * ch..]);
|
||||
}
|
||||
out
|
||||
/// Scratch buffers for the seam search, reused across seams.
|
||||
#[derive(Default)]
|
||||
struct Dp {
|
||||
cost: Vec<f32>,
|
||||
next: Vec<f32>,
|
||||
/// Per pixel, where the cheapest path came from: 0 = up-left, 1 = up, 2 = up-right.
|
||||
from: Vec<u8>,
|
||||
}
|
||||
|
||||
fn remove_seam_1(w: usize, h: usize, v: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
remove_seam(w, h, 1, v, seam)
|
||||
impl Dp {
|
||||
/// The lowest-energy seam of a `cw`-wide, `h`-tall energy map with rows `stride` apart.
|
||||
/// Ties go to the pixel straight up, then up-left, then up-right; at the bottom, to the
|
||||
/// leftmost end.
|
||||
fn seam(&mut self, stride: usize, cw: usize, h: usize, energy: &[f32]) -> Vec<usize> {
|
||||
self.cost.clear();
|
||||
self.cost.extend_from_slice(&energy[..cw]);
|
||||
self.next.resize(cw, 0.0);
|
||||
self.from.resize(cw * h, 0);
|
||||
for y in 1..h {
|
||||
let (cost, next) = (&self.cost[..cw], &mut self.next[..cw]);
|
||||
let e = &energy[y * stride..y * stride + cw];
|
||||
let from = &mut self.from[y * cw..(y + 1) * cw];
|
||||
if cw == 1 {
|
||||
(next[0], from[0]) = (cost[0] + e[0], 1);
|
||||
} else {
|
||||
let (b, d) = if cost[1] < cost[0] { (cost[1], 2) } else { (cost[0], 1) };
|
||||
(next[0], from[0]) = (b + e[0], d);
|
||||
// The interior has all three parents: no edge tests in the hot loop.
|
||||
let inner = next[1..cw - 1].iter_mut().zip(&mut from[1..cw - 1]).zip(&e[1..cw - 1]).zip(cost.windows(3));
|
||||
for (((n, f), &ev), c) in inner {
|
||||
let (b, d) = if c[0] < c[1] { (c[0], 0) } else { (c[1], 1) };
|
||||
let (b, d) = if c[2] < b { (c[2], 2) } else { (b, d) };
|
||||
(*n, *f) = (b + ev, d);
|
||||
}
|
||||
let (b, d) = if cost[cw - 2] < cost[cw - 1] { (cost[cw - 2], 0) } else { (cost[cw - 1], 1) };
|
||||
(next[cw - 1], from[cw - 1]) = (b + e[cw - 1], d);
|
||||
}
|
||||
std::mem::swap(&mut self.cost, &mut self.next);
|
||||
}
|
||||
let cost = &self.cost[..cw];
|
||||
let mut x = (0..cw).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
|
||||
let mut seam = vec![0usize; h];
|
||||
for y in (0..h).rev() {
|
||||
seam[y] = x;
|
||||
if y > 0 {
|
||||
x = match self.from[y * cw + x] {
|
||||
0 => x - 1,
|
||||
2 => x + 1,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
seam
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrinks an image one seam at a time in place. Rows keep their original stride, so removing a
|
||||
/// seam shifts only the rest of each row (in parallel), and the energy map is updated only next
|
||||
/// to the removed seam instead of being recomputed: the gradient of a pixel changes only when a
|
||||
/// neighbour does, which happens within a pixel of the seam in its own row and the rows around it.
|
||||
struct Carver {
|
||||
stride: usize,
|
||||
cw: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: Vec<f32>,
|
||||
prot: Option<Vec<f32>>,
|
||||
/// Energy plus protection, as the seam search sees it.
|
||||
e: Vec<f32>,
|
||||
/// Original column of each pixel, when the caller needs to know which ones were removed.
|
||||
idx: Option<Vec<usize>>,
|
||||
dp: Dp,
|
||||
}
|
||||
|
||||
impl Carver {
|
||||
fn new(w: usize, h: usize, ch: usize, img: Vec<f32>, prot: Option<Vec<f32>>, track: bool) -> Self {
|
||||
let mut e = energy(w, h, ch, &img);
|
||||
if let Some(p) = &prot {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
}
|
||||
let idx = track.then(|| (0..h).flat_map(|_| 0..w).collect());
|
||||
Carver { stride: w, cw: w, h, ch, img, prot, e, idx, dp: Dp::default() }
|
||||
}
|
||||
|
||||
/// Remove the lowest-energy seam. Returns, per row, the removed column (the original column
|
||||
/// when tracking).
|
||||
fn remove_seam(&mut self) -> Vec<usize> {
|
||||
let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch);
|
||||
let seam = self.dp.seam(stride, cw, h, &self.e);
|
||||
let removed = match &self.idx {
|
||||
Some(idx) => seam.iter().enumerate().map(|(y, &x)| idx[y * stride + x]).collect(),
|
||||
None => seam.clone(),
|
||||
};
|
||||
shift_rows(&mut self.img, stride * ch, &seam, ch, cw);
|
||||
shift_rows(&mut self.e, stride, &seam, 1, cw);
|
||||
if let Some(p) = &mut self.prot {
|
||||
shift_rows(p, stride, &seam, 1, cw);
|
||||
}
|
||||
if let Some(idx) = &mut self.idx {
|
||||
shift_rows(idx, stride, &seam, 1, cw);
|
||||
}
|
||||
self.cw -= 1;
|
||||
self.update_energy(&seam);
|
||||
removed
|
||||
}
|
||||
|
||||
/// Recompute the energy of the pixels whose neighbours changed when `seam` was removed: in
|
||||
/// row y, from one left of the leftmost of the seam's x in rows y-1..=y+1 to the rightmost.
|
||||
fn update_energy(&mut self, seam: &[usize]) {
|
||||
let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch);
|
||||
if cw == 0 {
|
||||
return;
|
||||
}
|
||||
let (img, prot) = (&self.img, self.prot.as_deref());
|
||||
let row = |(y, e): (usize, &mut [f32])| {
|
||||
let near = &seam[y.saturating_sub(1)..(y + 2).min(h)];
|
||||
let lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1));
|
||||
let hi = near.iter().max().map_or(0, |&s| s.min(cw - 1));
|
||||
for x in lo..=hi {
|
||||
let mut v = energy_at(img, stride, cw, h, ch, x, y);
|
||||
if let Some(p) = prot {
|
||||
v += p[y * stride + x].clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
e[x] = v;
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
self.e.par_chunks_mut(stride).enumerate().for_each(row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.e.chunks_mut(stride).enumerate().for_each(row);
|
||||
}
|
||||
|
||||
/// The carved pixels, `cw × h × ch`, rows packed.
|
||||
fn into_pixels(self) -> Vec<f32> {
|
||||
let (row, used) = (self.stride * self.ch, self.cw * self.ch);
|
||||
if row == used {
|
||||
return self.img;
|
||||
}
|
||||
self.img.chunks(row).flat_map(|r| &r[..used]).copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove pixel `seam[y]` (of `n` values) from each `row_len`-long row whose first `cw` pixels
|
||||
/// are in use, by shifting the rest of the row left.
|
||||
fn shift_rows<T: Copy + Send>(buf: &mut [T], row_len: usize, seam: &[usize], n: usize, cw: usize) {
|
||||
let shift = |(r, &x): (&mut [T], &usize)| r.copy_within((x + 1) * n..cw * n, x * n);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
buf.par_chunks_mut(row_len).zip(seam.par_iter()).for_each(shift);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
buf.chunks_mut(row_len).zip(seam).for_each(shift);
|
||||
}
|
||||
|
||||
fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
@@ -97,29 +236,25 @@ fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
|
||||
/// Change the width of `img` to `new_w` by removing or duplicating vertical seams.
|
||||
pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec<f32> {
|
||||
// Never cancelled; the fallback is unreachable.
|
||||
carve_width_with(w, h, ch, img, protect, new_w, &Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
|
||||
}
|
||||
|
||||
/// [`carve_width`] that checks `ctl` before every seam and reports progress per seam.
|
||||
pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
|
||||
let new_w = new_w.max(1);
|
||||
if new_w == w || w == 0 || h == 0 {
|
||||
return img.to_vec();
|
||||
return Ok(img.to_vec());
|
||||
}
|
||||
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
|
||||
if let Some(p) = p {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
}
|
||||
};
|
||||
let seams = new_w.abs_diff(w).max(1) as f32;
|
||||
if new_w < w {
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw > new_w {
|
||||
let mut e = energy(cw, h, ch, &cur);
|
||||
protect_e(&mut e, prot.as_ref());
|
||||
let seam = find_seam(cw, h, &e);
|
||||
cur = remove_seam(cw, h, ch, &cur, &seam);
|
||||
prot = prot.map(|p| remove_seam_1(cw, h, &p, &seam));
|
||||
cw -= 1;
|
||||
let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false);
|
||||
while carver.cw > new_w {
|
||||
ctl.check()?;
|
||||
ctl.progress((w - carver.cw) as f32 / seams);
|
||||
carver.remove_seam();
|
||||
}
|
||||
return cur;
|
||||
return Ok(carver.into_pixels());
|
||||
}
|
||||
// Enlarge in rounds of at most half the current width, each duplicating the k lowest seams.
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
@@ -127,23 +262,18 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
||||
while cw < new_w {
|
||||
let k = (new_w - cw).min((cw / 2).max(1));
|
||||
// Find k seams on a shrinking copy, mapping each back to original columns.
|
||||
let mut idx: Vec<Vec<usize>> = (0..h).map(|_| (0..cw).collect()).collect();
|
||||
let (mut tmp, mut tw) = (cur.clone(), cw);
|
||||
let mut tprot = prot.clone();
|
||||
let mut carver = Carver::new(cw, h, ch, cur.clone(), prot.clone(), true);
|
||||
let mut dup: Vec<Vec<usize>> = vec![Vec::with_capacity(k); h];
|
||||
for _ in 0..k {
|
||||
if tw <= 1 {
|
||||
// A 1 px wide image's only seam is its one column: take it, so it is duplicated.
|
||||
if carver.cw == 0 {
|
||||
break;
|
||||
}
|
||||
let mut e = energy(tw, h, ch, &tmp);
|
||||
protect_e(&mut e, tprot.as_ref());
|
||||
let seam = find_seam(tw, h, &e);
|
||||
for (y, &sx) in seam.iter().enumerate() {
|
||||
dup[y].push(idx[y].remove(sx));
|
||||
ctl.check()?;
|
||||
ctl.progress((cw + (cw - carver.cw)).saturating_sub(w) as f32 / seams);
|
||||
for (d, x) in dup.iter_mut().zip(carver.remove_seam()) {
|
||||
d.push(x);
|
||||
}
|
||||
tmp = remove_seam(tw, h, ch, &tmp, &seam);
|
||||
tprot = tprot.map(|p| remove_seam_1(tw, h, &p, &seam));
|
||||
tw -= 1;
|
||||
}
|
||||
let added = dup[0].len();
|
||||
if added == 0 {
|
||||
@@ -177,21 +307,44 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
|
||||
prot = nprot;
|
||||
cw = nw;
|
||||
}
|
||||
cur
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
carve_with(w, h, ch, img, protect, new_w, new_h, &Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
|
||||
}
|
||||
|
||||
/// [`carve`] that checks `ctl` before every seam (a cancel takes effect within one seam) and
|
||||
/// reports progress over both passes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn carve_with(
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
img: &[f32],
|
||||
protect: Option<&[f32]>,
|
||||
new_w: usize,
|
||||
new_h: usize,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<Vec<f32>, Cancelled> {
|
||||
let (new_w, new_h) = (new_w.max(1), new_h.max(1));
|
||||
let wide = carve_width(w, h, ch, img, protect, new_w);
|
||||
// Split progress between the passes by their seam counts.
|
||||
let (sw, sh) = (new_w.abs_diff(w) as f32, new_h.abs_diff(h) as f32);
|
||||
let split = if sw + sh > 0.0 { sw / (sw + sh) } else { 1.0 };
|
||||
let cancel = || ctl.cancelled();
|
||||
let first = |f: f32| ctl.progress(f * split);
|
||||
let wide = carve_width_with(w, h, ch, img, protect, new_w, &Interrupt::new(&cancel, &first))?;
|
||||
if new_h == h {
|
||||
return wide;
|
||||
return Ok(wide);
|
||||
}
|
||||
// Protection follows the width change by plain resampling (it only steers seams).
|
||||
let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h)));
|
||||
let t = transpose(new_w, h, ch, &wide);
|
||||
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
|
||||
transpose(new_h, new_w, ch, &tall)
|
||||
let second = |f: f32| ctl.progress(split + f * (1.0 - split));
|
||||
let tall = carve_width_with(h, new_w, ch, &t, prot_t.as_deref(), new_h, &Interrupt::new(&cancel, &second))?;
|
||||
Ok(transpose(new_h, new_w, ch, &tall))
|
||||
}
|
||||
|
||||
/// Plain bilinear resize (the non-content-aware part of the Amount blend).
|
||||
@@ -241,6 +394,211 @@ pub fn skin_mask(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The straightforward implementation the per-seam carver replaced (full energy map, fresh
|
||||
/// seam search and a new buffer per seam), kept as the oracle for `carver_matches_reference`.
|
||||
mod reference {
|
||||
use super::super::{PROTECT_ENERGY, resize_bilinear, transpose};
|
||||
|
||||
pub(super) fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
let mut e = vec![0.0f32; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1));
|
||||
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
let at = |xx: usize, yy: usize, c: usize| img[(yy * w + xx) * ch + c];
|
||||
let mut s = 0.0;
|
||||
for c in 0..ch {
|
||||
let v = at(x, y, c);
|
||||
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
|
||||
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
|
||||
}
|
||||
e[y * w + x] = s * 0.5;
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
pub(super) fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec<usize> {
|
||||
let mut cost = energy[..w].to_vec();
|
||||
let mut from = vec![0u8; w * h]; // 0 = up-left, 1 = up, 2 = up-right
|
||||
let mut next = vec![0.0f32; w];
|
||||
for y in 1..h {
|
||||
for x in 0..w {
|
||||
let mut best = (cost[x], 1u8);
|
||||
if x > 0 && cost[x - 1] < best.0 {
|
||||
best = (cost[x - 1], 0);
|
||||
}
|
||||
if x + 1 < w && cost[x + 1] < best.0 {
|
||||
best = (cost[x + 1], 2);
|
||||
}
|
||||
next[x] = best.0 + energy[y * w + x];
|
||||
from[y * w + x] = best.1;
|
||||
}
|
||||
std::mem::swap(&mut cost, &mut next);
|
||||
}
|
||||
let mut x = (0..w).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
|
||||
let mut seam = vec![0usize; h];
|
||||
for y in (0..h).rev() {
|
||||
seam[y] = x;
|
||||
if y > 0 {
|
||||
x = match from[y * w + x] {
|
||||
0 => x - 1,
|
||||
2 => x + 1,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
seam
|
||||
}
|
||||
fn remove_seam(w: usize, h: usize, ch: usize, img: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity((w - 1) * h * ch);
|
||||
for (y, &sx) in seam.iter().enumerate().take(h) {
|
||||
let row = &img[y * w * ch..(y + 1) * w * ch];
|
||||
out.extend_from_slice(&row[..sx * ch]);
|
||||
out.extend_from_slice(&row[(sx + 1) * ch..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
pub(super) fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec<f32> {
|
||||
let new_w = new_w.max(1);
|
||||
if new_w == w || w == 0 || h == 0 {
|
||||
return img.to_vec();
|
||||
}
|
||||
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
|
||||
if let Some(p) = p {
|
||||
for (e, p) in e.iter_mut().zip(p) {
|
||||
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
|
||||
}
|
||||
}
|
||||
};
|
||||
if new_w < w {
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw > new_w {
|
||||
let mut e = energy(cw, h, ch, &cur);
|
||||
protect_e(&mut e, prot.as_ref());
|
||||
let seam = find_seam(cw, h, &e);
|
||||
cur = remove_seam(cw, h, ch, &cur, &seam);
|
||||
prot = prot.map(|p| remove_seam(cw, h, 1, &p, &seam));
|
||||
cw -= 1;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
// Enlarge in rounds of at most half the current width, each duplicating the k lowest seams.
|
||||
let (mut cur, mut cw) = (img.to_vec(), w);
|
||||
let mut prot = protect.map(<[f32]>::to_vec);
|
||||
while cw < new_w {
|
||||
let k = (new_w - cw).min((cw / 2).max(1));
|
||||
// Find k seams on a shrinking copy, mapping each back to original columns.
|
||||
let mut idx: Vec<Vec<usize>> = (0..h).map(|_| (0..cw).collect()).collect();
|
||||
let (mut tmp, mut tw) = (cur.clone(), cw);
|
||||
let mut tprot = prot.clone();
|
||||
let mut dup: Vec<Vec<usize>> = vec![Vec::with_capacity(k); h];
|
||||
for _ in 0..k {
|
||||
// A 1 px wide image's only seam is its one column: take it, so it is duplicated.
|
||||
if tw == 0 {
|
||||
break;
|
||||
}
|
||||
let mut e = energy(tw, h, ch, &tmp);
|
||||
protect_e(&mut e, tprot.as_ref());
|
||||
let seam = find_seam(tw, h, &e);
|
||||
for (y, &sx) in seam.iter().enumerate() {
|
||||
dup[y].push(idx[y].remove(sx));
|
||||
}
|
||||
tmp = remove_seam(tw, h, ch, &tmp, &seam);
|
||||
tprot = tprot.map(|p| remove_seam(tw, h, 1, &p, &seam));
|
||||
tw -= 1;
|
||||
}
|
||||
let added = dup[0].len();
|
||||
if added == 0 {
|
||||
break;
|
||||
}
|
||||
let nw = cw + added;
|
||||
let mut out = Vec::with_capacity(nw * h * ch);
|
||||
let mut nprot = prot.as_ref().map(|_| Vec::with_capacity(nw * h));
|
||||
for (y, d) in dup.iter_mut().enumerate() {
|
||||
d.sort_unstable();
|
||||
let row = &cur[y * cw * ch..(y + 1) * cw * ch];
|
||||
let mut di = 0;
|
||||
for x in 0..cw {
|
||||
out.extend_from_slice(&row[x * ch..(x + 1) * ch]);
|
||||
if let (Some(np), Some(p)) = (nprot.as_mut(), prot.as_ref()) {
|
||||
np.push(p[y * cw + x]);
|
||||
}
|
||||
while di < d.len() && d[di] == x {
|
||||
let xr = (x + 1).min(cw - 1);
|
||||
for c in 0..ch {
|
||||
out.push((row[x * ch + c] + row[xr * ch + c]) * 0.5);
|
||||
}
|
||||
if let (Some(np), Some(p)) = (nprot.as_mut(), prot.as_ref()) {
|
||||
np.push(p[y * cw + x]);
|
||||
}
|
||||
di += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
cur = out;
|
||||
prot = nprot;
|
||||
cw = nw;
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
pub(super) fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
|
||||
let (new_w, new_h) = (new_w.max(1), new_h.max(1));
|
||||
let wide = carve_width(w, h, ch, img, protect, new_w);
|
||||
if new_h == h {
|
||||
return wide;
|
||||
}
|
||||
let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h)));
|
||||
let t = transpose(new_w, h, ch, &wide);
|
||||
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
|
||||
transpose(new_h, new_w, ch, &tall)
|
||||
}
|
||||
}
|
||||
|
||||
/// The carver gives bit-identical output to the reference implementation it replaced, over
|
||||
/// random shapes (including 1 px wide and tall), channel counts, tie-heavy quantised noise,
|
||||
/// protection, shrinking, enlarging past half the width and both axes at once.
|
||||
#[test]
|
||||
fn carver_matches_reference() {
|
||||
let mut seed = 1u32;
|
||||
let mut next = |n: usize| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed as usize % n
|
||||
};
|
||||
for case in 0..400u32 {
|
||||
let (w, h, ch) = (1 + next(24), 1 + next(18), [1, 3, 4][next(3)]);
|
||||
let levels = [2, 5, 255, 65_536][next(4)];
|
||||
let img = noise(w * h * ch, levels, 11 + case);
|
||||
let protect = (next(3) == 0).then(|| noise(w * h, 3, 101 + case));
|
||||
let (nw, nh) = (1 + next(2 * w + 2), 1 + next(2 * h + 2));
|
||||
let got = carve(w, h, ch, &img, protect.as_deref(), nw, nh);
|
||||
let want = reference::carve(w, h, ch, &img, protect.as_deref(), nw, nh);
|
||||
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
assert_eq!(bits(&got), bits(&want), "case {case}: {w}x{h}x{ch} -> {nw}x{nh}, levels {levels}, protect {}", protect.is_some());
|
||||
}
|
||||
// The seam search alone, on raw energy maps full of ties.
|
||||
for case in 0..200u32 {
|
||||
let (w, h) = (1 + next(40), 1 + next(30));
|
||||
let e = noise(w * h, [2, 3, 1000][next(3)], 7 + case);
|
||||
assert_eq!(find_seam(w, h, &e), reference::find_seam(w, h, &e), "seam case {case}: {w}x{h}");
|
||||
let img = noise(w * h * 3, 7, 3 + case);
|
||||
let bits = |v: Vec<f32>| v.into_iter().map(f32::to_bits).collect::<Vec<_>>();
|
||||
assert_eq!(bits(energy(w, h, 3, &img)), bits(reference::energy(w, h, 3, &img)), "energy case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_carve_stops() {
|
||||
let (w, h) = (40, 20);
|
||||
let img = vec![0.5f32; w * h * 3];
|
||||
let yes = || true;
|
||||
assert_eq!(carve_with(w, h, 3, &img, None, 30, 15, &Interrupt::cancel_only(&yes)), Err(Cancelled));
|
||||
assert_eq!(carve_with(w, h, 3, &img, None, 30, 15, &Interrupt::NONE).unwrap(), carve(w, h, 3, &img, None, 30, 15));
|
||||
}
|
||||
|
||||
/// A flat grey image with a salient vertical bar at x = 10..14 (red/blue columns).
|
||||
fn bar(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut v = vec![0.5f32; w * h * 3];
|
||||
@@ -315,6 +673,77 @@ mod tests {
|
||||
assert!((0..4).all(|y| out[y * 8..y * 8 + 8].iter().any(|v| (*v - 0.51).abs() < 1e-6)));
|
||||
}
|
||||
|
||||
/// Noise quantised to `levels` values, so the energy map is full of exact ties.
|
||||
fn noise(len: usize, levels: u32, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s % levels) as f32 / (levels - 1) as f32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fnv(v: &[f32]) -> u64 {
|
||||
v.iter().flat_map(|f| f.to_bits().to_le_bytes()).fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3))
|
||||
}
|
||||
|
||||
/// The exact output bits of a spread of carves (shrink, enlarge past half the width, both
|
||||
/// axes, protection, 1/3/4 channels, tie-heavy and smooth images), pinned so that speed work
|
||||
/// can't change which seams are chosen or how pixels are blended.
|
||||
#[test]
|
||||
fn output_bits_are_pinned() {
|
||||
let cases: [(usize, usize, usize, u32, bool, usize, usize); 8] = [
|
||||
(61, 47, 3, 2, false, 40, 47),
|
||||
(61, 47, 3, 255, false, 61, 30),
|
||||
(61, 47, 4, 4, true, 35, 29),
|
||||
(33, 20, 1, 3, false, 80, 20),
|
||||
(33, 20, 3, 1000, true, 50, 45),
|
||||
(48, 36, 4, 7, false, 70, 18),
|
||||
(5, 3, 1, 2, false, 1, 1),
|
||||
(40, 40, 3, 2, true, 39, 41),
|
||||
];
|
||||
let got: Vec<u64> = cases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(w, h, ch, levels, prot, nw, nh))| {
|
||||
let img = noise(w * h * ch, levels, 7 + i as u32);
|
||||
let protect = prot.then(|| noise(w * h, 3, 99 + i as u32));
|
||||
let out = carve(w, h, ch, &img, protect.as_deref(), nw, nh);
|
||||
assert_eq!(out.len(), nw * nh * ch);
|
||||
fnv(&out)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, PINNED);
|
||||
}
|
||||
|
||||
const PINNED: [u64; 8] = [
|
||||
0x24ea_9353_02c2_7c58,
|
||||
0x5a6e_8554_a0cb_ebb1,
|
||||
0x8b18_d63f_396e_21c9,
|
||||
0x1db2_23ad_3e6a_a7c5,
|
||||
0x721d_6cc5_34e0_3fdf,
|
||||
0x961a_ea6d_e512_ec7d,
|
||||
0x4d25_767f_9dce_13f5,
|
||||
0x2157_9f86_27d6_b835,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn one_pixel_lines_enlarge_by_repeating() {
|
||||
// Width 1 has no seam to spare: its one column is repeated. Height 1 likewise (through
|
||||
// the transposed pass), alone or with the other axis.
|
||||
let col = [0.1f32, 0.2, 0.3, 0.4, 0.5];
|
||||
assert_eq!(carve(1, 5, 1, &col, None, 4, 5), col.iter().flat_map(|v| [*v; 4]).collect::<Vec<_>>());
|
||||
assert_eq!(carve(5, 1, 1, &col, None, 5, 3), col.repeat(3));
|
||||
assert_eq!(carve(1, 1, 3, &[0.7, 0.8, 0.9], None, 3, 2), [0.7, 0.8, 0.9].repeat(6));
|
||||
for (w, h, nw, nh) in [(1, 5, 6, 10), (1, 17, 6, 34), (16, 1, 21, 2), (7, 1, 3, 8), (2, 1, 1, 8)] {
|
||||
let img = vec![0.5f32; w * h * 4];
|
||||
assert_eq!(carve(w, h, 4, &img, Some(&vec![1.0; w * h]), nw, nh).len(), nw * nh * 4, "{w}x{h} -> {nw}x{nh}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_rule() {
|
||||
let px = [0.86f32, 0.62, 0.5, 0.2, 0.4, 0.8];
|
||||
|
||||
@@ -74,6 +74,7 @@ fn new_filters() -> Vec<FilterParams> {
|
||||
height: 50.0,
|
||||
white_is_high: true,
|
||||
},
|
||||
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
|
||||
FilterParams::ReduceNoise { strength: 6.0, preserve_details: 60.0, reduce_color_noise: 45.0, sharpen_details: 25.0, remove_jpeg_artifact: true },
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 40.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::Low, mode: SmartBlurMode::OverlayEdge },
|
||||
@@ -666,3 +667,28 @@ fn oil_paint_smooths_along_strokes() {
|
||||
let inner = Rect::new(8, 8, 32, 22);
|
||||
assert!(variance(&out, inner, 0) < variance(&s, inner, 0) * 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_filters_return_none_and_progress_never_regresses() {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
let s = pattern(SampleType::U8, R);
|
||||
let p = FilterParams::GaussianBlur { radius: 3.0 };
|
||||
let area = output_area(&p, s.content_bounds(), R, None);
|
||||
let yes = || true;
|
||||
assert!(apply_tiled_with(&s, &p, area, R, None, 16, Some(R), &photocraft_raster::Interrupt::cancel_only(&yes)).is_none());
|
||||
// Tile workers report progress from their own threads, so values can arrive out of order;
|
||||
// consumers keep the high-water mark (as `JobCtx::progress` does with `fetch_max`).
|
||||
// Non-negative f32s order like their bits, so `fetch_max` on the bits needs no lock.
|
||||
let high = AtomicU32::new(0);
|
||||
let calls = AtomicU32::new(0);
|
||||
let no = || false;
|
||||
let progress = |f: f32| {
|
||||
assert!((0.0..=1.0).contains(&f), "progress in range: {f}");
|
||||
high.fetch_max(f.to_bits(), Ordering::Relaxed);
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
};
|
||||
let out = apply_tiled_with(&s, &p, area, R, None, 16, Some(R), &photocraft_raster::Interrupt::new(&no, &progress)).unwrap();
|
||||
assert_eq!(f32::from_bits(high.load(Ordering::Relaxed)), 1.0);
|
||||
assert!(calls.load(Ordering::Relaxed) >= 1);
|
||||
assert_eq!(out.read_region(R), apply_tiled(&s, &p, area, R, None, 16, Some(R)).read_region(R));
|
||||
}
|
||||
@@ -343,7 +343,9 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
|
||||
let c = [q[0], q[1], q[2]];
|
||||
let l0 = luma(c).max(0.0) + EPS;
|
||||
let new_log = mean + (base[i] - mean) * compress + (logl[i] - base[i]) * detail;
|
||||
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(gamma);
|
||||
// The contract is "lower = more contrast", i.e. gamma is the divisor of
|
||||
// the exponent, matching the crate's other gamma controls (hdr.rs, proof_sim.rs).
|
||||
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(1.0 / gamma);
|
||||
l1 += p.shadow / 100.0 * (1.0 - l1).powi(3) * 0.5 - p.highlight / 100.0 * l1.powi(3) * 0.5;
|
||||
l1 = curve_eval(&sorted, l1.clamp(0.0, 1.0));
|
||||
let ratio = l1 / l0;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Bounded hue/saturation distribution. Input colours must already be in the chosen RGB space.
|
||||
//! Display orientation and colour management belong to callers, not to this analysis.
|
||||
pub const HUES: usize = 128;
|
||||
pub const SATURATIONS: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vectorscope {
|
||||
pub counts: Vec<u32>,
|
||||
pub samples: u64,
|
||||
}
|
||||
|
||||
impl Default for Vectorscope {
|
||||
fn default() -> Self {
|
||||
Self { counts: vec![0; HUES * SATURATIONS], samples: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Vectorscope {
|
||||
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
|
||||
let mut out = Self::default();
|
||||
for p in pixels {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let rgb = [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0), p[2].clamp(0.0, 1.0)];
|
||||
let (h, s) = hue_saturation(rgb);
|
||||
let hue = ((h * HUES as f32) as usize).min(HUES - 1);
|
||||
let saturation = ((s * (SATURATIONS - 1) as f32).round() as usize).min(SATURATIONS - 1);
|
||||
if let Some(n) = out.counts.get_mut(hue * SATURATIONS + saturation) {
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
out.samples = out.samples.saturating_add(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue as turns and saturation in 0..1; achromatic pixels have zero saturation.
|
||||
pub fn hue_saturation([r, g, b]: [f32; 3]) -> (f32, f32) {
|
||||
let high = r.max(g).max(b);
|
||||
let low = r.min(g).min(b);
|
||||
let delta = high - low;
|
||||
if delta <= f32::EPSILON || high <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let h = if high == r {
|
||||
(g - b) / delta
|
||||
} else if high == g {
|
||||
2.0 + (b - r) / delta
|
||||
} else {
|
||||
4.0 + (r - g) / delta
|
||||
};
|
||||
((h / 6.0).rem_euclid(1.0), delta / high)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rgb_primary_positions_neutrals_and_transparency() {
|
||||
for (rgb, expected) in [([1.0, 0.0, 0.0], 0.0), ([1.0, 1.0, 0.0], 1.0 / 6.0), ([0.0, 1.0, 0.0], 1.0 / 3.0), ([0.0, 0.0, 1.0], 2.0 / 3.0)] {
|
||||
let (h, s) = hue_saturation(rgb);
|
||||
assert!((h - expected).abs() < 1e-6);
|
||||
assert_eq!(s, 1.0);
|
||||
}
|
||||
let v = Vectorscope::from_rgba(&[[0.5, 0.5, 0.5, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]]);
|
||||
assert_eq!(v.samples, 2);
|
||||
assert_eq!(v.counts[0], 1);
|
||||
assert_eq!(v.counts[SATURATIONS - 1], 1);
|
||||
assert_eq!(v.counts.iter().map(|&c| u64::from(c)).sum::<u64>(), v.samples);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,19 @@ pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine,
|
||||
crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// [`place_source`] through a projective map `h` (source → document; Distort, Perspective): the
|
||||
/// warp first (in source space), then `h`.
|
||||
pub fn place_source_projective(src: &Surface, src_rect: Rect, h: &crate::transform::Homography, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
|
||||
if let Some(w) = warp.filter(|w| !w.is_identity()) {
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
h.apply(u, v)
|
||||
};
|
||||
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
|
||||
}
|
||||
crate::transform::warp_surface(src, src_rect, h, Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// Warps the content of `src` inside `src_rect` through the forward map `f` (source document
|
||||
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
|
||||
/// added; pixels outside the warped area are transparent.
|
||||
|
||||
@@ -103,19 +103,34 @@ pub fn write_reply(out: &mut impl Write, reply: &Value) -> io::Result<()> {
|
||||
/// many individually valid replies cannot accumulate into an enormous batch.
|
||||
pub struct BatchReplyBudget {
|
||||
remaining: usize,
|
||||
/// Charge each result as it costs inside a JSON string (MCP text content).
|
||||
escaped: bool,
|
||||
}
|
||||
|
||||
impl Default for BatchReplyBudget {
|
||||
fn default() -> Self {
|
||||
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096 }
|
||||
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096, escaped: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl BatchReplyBudget {
|
||||
/// For a batch reply sent as MCP text content: the encoded reply is embedded in a JSON
|
||||
/// string, which escapes every quote and backslash again, so a batch the plain budget accepts
|
||||
/// could exceed the tool-result ceiling and lose every step result.
|
||||
pub fn escaped() -> Self {
|
||||
Self { escaped: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// Must be called before retaining the result or dispatching the next step.
|
||||
pub fn charge(&mut self, result: &Value) -> Result<(), AutomationError> {
|
||||
let bytes = encode_with_limit(result, self.remaining.saturating_sub(1))?;
|
||||
self.remaining = self.remaining.saturating_sub(bytes.len() + 1);
|
||||
let limit = self.remaining.saturating_sub(1);
|
||||
let bytes = encode_with_limit(result, limit)?;
|
||||
// Compact JSON has no raw control characters, so only `"` and `\` grow when escaped.
|
||||
let size = if self.escaped { bytes.len() + bytes.iter().filter(|&&b| b == b'"' || b == b'\\').count() } else { bytes.len() };
|
||||
if size > limit {
|
||||
return Err(bad(format!("response exceeds {limit} bytes once escaped")));
|
||||
}
|
||||
self.remaining = self.remaining.saturating_sub(size + 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -161,9 +176,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_cannot_retain_more_than_the_aggregate_reply_budget() {
|
||||
let mut budget = BatchReplyBudget { remaining: 10 };
|
||||
let mut budget = BatchReplyBudget { remaining: 10, escaped: false };
|
||||
budget.charge(&json!("abc")).unwrap();
|
||||
assert!(budget.charge(&json!("abc")).is_err());
|
||||
assert_eq!(budget.remaining, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_budget_charges_the_size_inside_a_json_string() {
|
||||
// `"a\"b"` is 6 bytes plain and 10 once embedded in a string (`\"a\\\"b\"`).
|
||||
let value = json!("a\"b");
|
||||
let escaped_len = serde_json::to_string(&Value::String(value.to_string())).unwrap().len() - 2;
|
||||
assert_eq!(escaped_len, 10);
|
||||
let mut budget = BatchReplyBudget { remaining: 11, escaped: true };
|
||||
budget.charge(&value).unwrap();
|
||||
assert_eq!(budget.remaining, 0);
|
||||
let mut budget = BatchReplyBudget { remaining: 10, escaped: true };
|
||||
assert!(budget.charge(&value).is_err());
|
||||
assert!(BatchReplyBudget { remaining: 10, escaped: false }.charge(&value).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() });
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
open_bytes(&name, &bytes)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_engine::{Session, command_specs};
|
||||
use photocraft_engine::{Session, command_specs, file_cmds};
|
||||
use photocraft_format::PcraftWriter;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::workspace::authorize_engine_command;
|
||||
use crate::workspace::{authorize_engine_command, authorize_engine_step};
|
||||
use crate::{AuthorizedWorkspace, AutomationError, files};
|
||||
|
||||
fn untrusted(filesystem: Filesystem) -> Headless {
|
||||
let mut session = Session::new();
|
||||
session.authorize = Some(authorize_engine_step);
|
||||
Headless { session, writers: HashMap::new(), filesystem }
|
||||
}
|
||||
|
||||
enum Filesystem {
|
||||
Denied,
|
||||
TrustedLocal,
|
||||
@@ -28,7 +34,7 @@ pub struct Headless {
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Denied }
|
||||
untrusted(Filesystem::Denied)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +45,23 @@ impl Headless {
|
||||
}
|
||||
|
||||
/// Create a session for an explicit local CLI invocation. The CLI caller,
|
||||
/// not a remote automation client, supplies these host paths.
|
||||
/// not a remote automation client, supplies these host paths. Nested
|
||||
/// `actions.play` steps are not re-checked: this caller is already trusted.
|
||||
pub fn trusted_local() -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::TrustedLocal }
|
||||
}
|
||||
|
||||
/// Create an automation session with capability-scoped file access.
|
||||
/// Each step of `actions.play` is checked with [`authorize_engine_step`].
|
||||
pub fn with_workspace(workspace: AuthorizedWorkspace) -> Self {
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Workspace(workspace) }
|
||||
untrusted(Filesystem::Workspace(workspace))
|
||||
}
|
||||
|
||||
/// Apply background jobs that finished since the last request, so every request (save,
|
||||
/// export, inspect, preview, `session.list`, commands) sees their result. Cheap when no job
|
||||
/// runs. The JSON-lines server and the MCP server call it before each request.
|
||||
pub fn sync_jobs(&mut self) {
|
||||
self.session.poll_jobs();
|
||||
}
|
||||
|
||||
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
|
||||
@@ -60,7 +75,7 @@ impl Headless {
|
||||
/// Open a file and make it the active document.
|
||||
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let o = match &self.filesystem {
|
||||
let mut o = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
Filesystem::TrustedLocal => files::open(path)?,
|
||||
Filesystem::Workspace(workspace) => {
|
||||
@@ -69,7 +84,14 @@ impl Headless {
|
||||
files::open_bytes(name, &bytes)?
|
||||
}
|
||||
};
|
||||
let index = self.session.add_document(o.document, Some(requested.to_string()));
|
||||
let path = match file_cmds::template_name(&self.session, requested) {
|
||||
Some(untitled) => {
|
||||
o.document.name = untitled;
|
||||
None
|
||||
}
|
||||
None => Some(requested.to_string()),
|
||||
};
|
||||
let index = self.session.add_document(o.document, path);
|
||||
let d = &self.session.documents()[index];
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
@@ -91,7 +113,18 @@ impl Headless {
|
||||
};
|
||||
let target: PathBuf = match (path, stored_path.as_deref()) {
|
||||
(Some(p), _) => p.to_path_buf(),
|
||||
(None, Some(p)) => PathBuf::from(p),
|
||||
// Like the desktop's File › Save: without a new path, only a layered file is written
|
||||
// back, in its own format; a flattened or converted copy never replaces it (#416).
|
||||
(None, Some(p)) => {
|
||||
let own = file_cmds::extension(p);
|
||||
let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone());
|
||||
if !file_cmds::saves_in_place(p) || written != own {
|
||||
return Err(AutomationError::BadRequest(format!(
|
||||
"pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged"
|
||||
)));
|
||||
}
|
||||
PathBuf::from(p)
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(AutomationError::BadRequest("document has no path; pass `path`".into()));
|
||||
}
|
||||
@@ -185,10 +218,25 @@ impl Headless {
|
||||
}
|
||||
|
||||
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
self.command_start(id, params, true)
|
||||
}
|
||||
|
||||
/// Run a command; with `wait` false, a job-capable command (filters, Content-Aware Fill,
|
||||
/// Photomerge, …) runs in the background and this returns `{"job": id}` at once (poll with
|
||||
/// `jobs.list`, stop with `jobs.cancel`). Other commands finish before returning either way.
|
||||
pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result<Value, AutomationError> {
|
||||
let params = if params.is_null() { json!({}) } else { params };
|
||||
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
|
||||
authorize_engine_command(id, ¶ms)?;
|
||||
}
|
||||
Ok(self.session.execute(id, params)?)
|
||||
// Background jobs that finished since the last request (or batch step) are applied first.
|
||||
self.sync_jobs();
|
||||
if wait {
|
||||
return Ok(self.session.execute(id, params)?);
|
||||
}
|
||||
Ok(match self.session.start(id, params)? {
|
||||
photocraft_engine::jobs::Started::Done(v) => v,
|
||||
photocraft_engine::jobs::Started::Job(job) => json!({"job": job.0, "pending": true}),
|
||||
})
|
||||
}
|
||||
}
|
||||
+166
-22
@@ -5,12 +5,13 @@
|
||||
//! loopback TCP port.
|
||||
//!
|
||||
//! Methods (camelCase params):
|
||||
//! - `engine.execute {command, params?}` / `engine.commands {filter?}`
|
||||
//! - `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.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
|
||||
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
|
||||
//! - `batch {steps: [{command, params?} | {method, params?}], stopOnError?}`
|
||||
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
|
||||
//! - `methods`: this list.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
@@ -23,13 +24,16 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::budgets::{BatchReplyBudget, write_reply};
|
||||
use crate::security::{
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
|
||||
read_bounded_line,
|
||||
};
|
||||
use crate::{AutomationError, Headless};
|
||||
|
||||
/// Method names served by [`Headless::handle`].
|
||||
pub const METHODS: &[&str] = &[
|
||||
"engine.execute",
|
||||
"jobs.list",
|
||||
"jobs.cancel",
|
||||
"engine.commands",
|
||||
"session.list",
|
||||
"doc.open",
|
||||
@@ -58,12 +62,17 @@ fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
||||
impl Headless {
|
||||
/// Dispatch one request. Unknown methods and bad params are errors, never panics.
|
||||
pub fn handle(&mut self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
self.sync_jobs();
|
||||
let p = if params.is_null() { json!({}) } else { params };
|
||||
match method {
|
||||
"engine.execute" => {
|
||||
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
|
||||
self.command_run(id, p.get("params").cloned().unwrap_or(Value::Null))
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
|
||||
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
|
||||
}
|
||||
// Background jobs (#210): list (applying any that finished) and cancel.
|
||||
"jobs.list" => self.command_start("jobs.list", json!({}), true),
|
||||
"jobs.cancel" => self.command_start("jobs.cancel", p, true),
|
||||
"engine.commands" => {
|
||||
let all = self.command_list();
|
||||
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
|
||||
@@ -88,7 +97,8 @@ impl Headless {
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), ..Default::default() };
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
}
|
||||
@@ -126,10 +136,16 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `steps` in order. Each step is `{command, params?}` (an engine command) or
|
||||
/// Run `steps` in order. Each step is `{command, params?, wait?}` (an engine command) or
|
||||
/// `{method, params?}` (any [`METHODS`] entry). Stops at the first error unless
|
||||
/// `stopOnError` is false; the reply lists every step's result.
|
||||
pub fn batch(&mut self, p: &Value) -> Result<Value, AutomationError> {
|
||||
self.batch_with_budget(p, BatchReplyBudget::default())
|
||||
}
|
||||
|
||||
/// [`Headless::batch`] with a caller's reply budget (MCP charges escaped sizes, see
|
||||
/// [`BatchReplyBudget::escaped`]). Steps stop once the budget runs out.
|
||||
pub fn batch_with_budget(&mut self, p: &Value, mut reply_budget: BatchReplyBudget) -> Result<Value, AutomationError> {
|
||||
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("batch needs `steps`"))?;
|
||||
if steps.len() > MAX_BATCH_STEPS {
|
||||
return Err(bad(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", steps.len())));
|
||||
@@ -137,11 +153,12 @@ impl Headless {
|
||||
let stop = p.get("stopOnError").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
let mut failed = 0usize;
|
||||
let mut reply_budget = BatchReplyBudget::default();
|
||||
for (i, s) in steps.iter().enumerate() {
|
||||
let params = s.get("params").cloned().unwrap_or(Value::Null);
|
||||
let r = if let Some(c) = str_of(s, "command") {
|
||||
self.command_run(c, params)
|
||||
// Like `engine.execute`: `wait: false` starts a long command as a background job.
|
||||
let wait = s.get("wait").and_then(Value::as_bool).unwrap_or(true);
|
||||
self.command_start(c, params, wait)
|
||||
} else if let Some(m) = str_of(s, "method") {
|
||||
if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) }
|
||||
} else {
|
||||
@@ -192,25 +209,24 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored.
|
||||
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored. An over-long or non-UTF-8
|
||||
/// line gets an error reply and is skipped; the session (and its open documents) keeps serving.
|
||||
pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write) -> std::io::Result<()> {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
match read_bounded_line(&mut r, &mut line)? {
|
||||
LineRead::Eof => return Ok(()),
|
||||
LineRead::TooLong => {
|
||||
write_reply(&mut w, &json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}))?;
|
||||
w.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
LineRead::Line => {}
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = respond(h, &line);
|
||||
let (reply, unread_rest) = match read_bounded_line(&mut r, &mut line) {
|
||||
Ok(LineRead::Eof) => return Ok(()),
|
||||
Ok(LineRead::TooLong) => (json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}), !line.ends_with('\n')),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => (json!({"id": null, "ok": false, "error": "request is not valid UTF-8"}), false),
|
||||
Err(e) => return Err(e),
|
||||
Ok(LineRead::Line) if line.trim().is_empty() => continue,
|
||||
Ok(LineRead::Line) => (respond(h, &line), false),
|
||||
};
|
||||
write_reply(&mut w, &reply)?;
|
||||
w.flush()?;
|
||||
if unread_rest {
|
||||
discard_rest_of_line(&mut r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +394,96 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_jobs_list_wait_and_cancel() {
|
||||
let mut h = Headless::new();
|
||||
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "edit.fill", "params": {"color": "#808080"}})).unwrap();
|
||||
// Default: waits, the result is the command's.
|
||||
let r = h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 20}})).unwrap();
|
||||
assert!(r["filter"].is_object(), "{r}");
|
||||
// wait: false returns a job id at once.
|
||||
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
|
||||
let job = r["job"].as_u64().unwrap();
|
||||
let listed = h.handle("jobs.list", json!({})).unwrap();
|
||||
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
|
||||
// Wait for it via jobs.list (which applies finished jobs).
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
let l = h.handle("jobs.list", json!({})).unwrap();
|
||||
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
|
||||
if state == Some(json!("done")) {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "{l}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
// Cancel: a second job, cancelled at once, leaves the document unchanged.
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
|
||||
let job = r["job"].as_u64().unwrap();
|
||||
h.handle("jobs.cancel", json!({"job": job})).unwrap();
|
||||
assert!(h.handle("jobs.cancel", json!({"job": "nope"})).is_err());
|
||||
let l = h.handle("jobs.list", json!({})).unwrap();
|
||||
assert_eq!(l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).unwrap()["state"], "cancelled");
|
||||
assert!(std::sync::Arc::ptr_eq(&h.session.active().unwrap().doc, &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_steps_can_start_background_jobs() {
|
||||
let mut h = Headless::new();
|
||||
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
let steps = json!([
|
||||
{"command": "layer.new.layer"},
|
||||
{"command": "edit.fill", "params": {"color": "#808080"}},
|
||||
{"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false}
|
||||
]);
|
||||
let r = h.handle("batch", json!({"steps": steps})).unwrap();
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let job = r["results"][2]["result"]["job"].as_u64().unwrap_or_else(|| panic!("no job id: {r}"));
|
||||
assert_eq!(r["results"][2]["result"]["pending"], true);
|
||||
let listed = h.handle("jobs.list", json!({})).unwrap();
|
||||
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
|
||||
let t = std::time::Instant::now();
|
||||
while h.session.jobs().iter().any(|j| j.id.0 == job) {
|
||||
assert!(t.elapsed().as_secs() < 60);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
h.handle("jobs.list", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression (#503): a finished `wait: false` job was only applied by a later command or
|
||||
/// `jobs.list`, so save, inspect, render and `session.list` still saw the document without it.
|
||||
#[test]
|
||||
fn every_request_sees_a_finished_background_job() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-rpc-job-sync-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (before, after) = (dir.join("before.png"), dir.join("after.png"));
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 50}})).unwrap();
|
||||
h.handle("doc.save", json!({"path": before.to_string_lossy()})).unwrap();
|
||||
let render = |h: &mut Headless| h.handle("doc.render", json!({"maxSide": 0})).unwrap()["base64"].clone();
|
||||
let unblurred = render(&mut h);
|
||||
let revision = |h: &mut Headless| h.handle("session.list", json!({})).unwrap()["documents"][0]["revision"].clone();
|
||||
let start = revision(&mut h);
|
||||
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 4}, "wait": false})).unwrap();
|
||||
// Only reading requests from here on: no command and no `jobs.list`.
|
||||
let t = std::time::Instant::now();
|
||||
while revision(&mut h) == start {
|
||||
assert!(t.elapsed().as_secs() < 60, "session.list never saw the finished job");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let history = h.handle("doc.inspect", json!({})).unwrap()["history"].clone();
|
||||
assert_eq!(history.as_array().unwrap().last().unwrap(), "Gaussian Blur", "{history}");
|
||||
h.handle("doc.save", json!({"path": after.to_string_lossy()})).unwrap();
|
||||
let decode = |p: &std::path::Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap().to_rgba8();
|
||||
assert_ne!(decode(&before), decode(&after), "the export holds the blur");
|
||||
assert_ne!(render(&mut h), unblurred, "the preview holds the blur");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
|
||||
let mut h = Headless::new();
|
||||
@@ -404,6 +510,28 @@ mod tests {
|
||||
assert!(h.lock().unwrap().session.documents().is_empty());
|
||||
}
|
||||
|
||||
/// Regression (#505): an over-long or non-UTF-8 line ended the stdio session, losing every
|
||||
/// open document. Now it gets one error reply, the rest of the line is skipped (never
|
||||
/// dispatched), and the next request is served.
|
||||
#[test]
|
||||
fn stdio_skips_a_rejected_line_and_keeps_the_session() {
|
||||
let h = Mutex::new(Headless::new());
|
||||
let mut input = b"{\"id\":1,\"method\":\"doc.new\",\"params\":{\"width\":8,\"height\":8}}\n".to_vec();
|
||||
input.extend(" ".repeat(MAX_REQUEST_BYTES + 1).into_bytes());
|
||||
input.extend(b"{\"id\":9,\"method\":\"doc.close\"}\n");
|
||||
input.extend(b"\xff\xfe{\"id\":8,\"method\":\"doc.close\"}\n");
|
||||
input.extend(b"{\"id\":2,\"method\":\"session.list\"}\n");
|
||||
let mut out = Vec::new();
|
||||
serve_lines(&h, input.as_slice(), &mut out).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], true);
|
||||
assert!(replies[1]["error"].as_str().unwrap().contains("request exceeds"), "{}", replies[1]);
|
||||
assert!(replies[2]["error"].as_str().unwrap().contains("not valid UTF-8"), "{}", replies[2]);
|
||||
assert_eq!(replies[3]["id"], 2);
|
||||
assert_eq!(replies[3]["result"]["documents"].as_array().unwrap().len(), 1, "the document stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_rejection_precedes_output_file_creation() {
|
||||
let root = std::env::temp_dir().join(format!("pc-preview-budget-{}", std::process::id()));
|
||||
@@ -431,6 +559,22 @@ mod tests {
|
||||
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (2049, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_lists_the_job_methods_and_every_listed_name_is_served() {
|
||||
let mut h = Headless::new();
|
||||
let listed = h.handle("methods", Value::Null).unwrap();
|
||||
let listed: Vec<&str> = listed.as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect();
|
||||
assert_eq!(listed, METHODS);
|
||||
// The job methods `engine.execute {"wait": false}` depends on are discoverable (#414).
|
||||
assert!(listed.contains(&"jobs.list") && listed.contains(&"jobs.cancel"));
|
||||
for m in METHODS {
|
||||
if let Err(e) = h.handle(m, Value::Null) {
|
||||
assert!(!e.to_string().contains("unknown method"), "{m} is listed but not served: {e}");
|
||||
}
|
||||
}
|
||||
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
|
||||
let mut h = Headless::new();
|
||||
|
||||
@@ -34,19 +34,43 @@ pub enum LineRead {
|
||||
}
|
||||
|
||||
/// Read one line without ever buffering more than [`MAX_REQUEST_BYTES`] plus one byte.
|
||||
///
|
||||
/// `TooLong` leaves the bytes read so far in `line` (lossily decoded); the rest of that line is
|
||||
/// still unread unless `line` ends with a newline (see [`discard_rest_of_line`]). A line within
|
||||
/// the limit that is not UTF-8 is an `InvalidData` error, and the whole line has been consumed.
|
||||
pub fn read_bounded_line(reader: &mut impl BufRead, line: &mut String) -> std::io::Result<LineRead> {
|
||||
line.clear();
|
||||
let mut limited = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64);
|
||||
let n = limited.read_line(line)?;
|
||||
let mut bytes = Vec::new();
|
||||
let n = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64).read_until(b'\n', &mut bytes)?;
|
||||
if n == 0 {
|
||||
Ok(LineRead::Eof)
|
||||
} else if n > MAX_REQUEST_BYTES {
|
||||
// A cut can split a multi-byte character; the length, not the text, decides.
|
||||
line.push_str(&String::from_utf8_lossy(&bytes));
|
||||
Ok(LineRead::TooLong)
|
||||
} else {
|
||||
*line = String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
Ok(LineRead::Line)
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip the rest of the current line (through its newline, or to EOF) without buffering it, so
|
||||
/// a stream transport can answer an over-long request and keep reading the next one.
|
||||
pub fn discard_rest_of_line(reader: &mut impl BufRead) -> std::io::Result<()> {
|
||||
loop {
|
||||
let buf = reader.fill_buf()?;
|
||||
if buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
|
||||
reader.consume(end + 1);
|
||||
return Ok(());
|
||||
}
|
||||
let n = buf.len();
|
||||
reader.consume(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a 256-bit bearer token with the operating system CSPRNG.
|
||||
pub fn generate_token() -> Result<String, AutomationError> {
|
||||
let mut bytes = [0u8; TOKEN_BYTES];
|
||||
@@ -235,6 +259,25 @@ mod tests {
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::TooLong);
|
||||
assert_eq!(line.len(), MAX_REQUEST_BYTES + 1);
|
||||
discard_rest_of_line(&mut reader).unwrap();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Eof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_reports_a_cut_multibyte_line_as_too_long_and_bad_utf8_as_invalid_data() {
|
||||
// The cut at the limit falls inside a two-byte character.
|
||||
let input = format!("xx{}\nnext\n", "é".repeat(MAX_REQUEST_BYTES / 2));
|
||||
let mut reader = std::io::Cursor::new(input);
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::TooLong);
|
||||
discard_rest_of_line(&mut reader).unwrap();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "next\n");
|
||||
// Invalid UTF-8 consumes its whole line, so the next read starts on the next line.
|
||||
let mut reader = std::io::Cursor::new(b"\xff\xfe\nnext\n".to_vec());
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "next\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+139
-24
@@ -72,7 +72,9 @@ pub struct NewParams {
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct SaveParams {
|
||||
/// Forward-slash relative target beneath the configured automation write root.
|
||||
/// The extension selects the format. Omit to save to the document's own relative path.
|
||||
/// The extension selects the format. Omit to write back to the document's own file, which
|
||||
/// works only for a PSD, PSB or .pcraft file kept in its own format; any other save needs
|
||||
/// `path`, so a flattened or converted copy never replaces the opened file.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
|
||||
@@ -81,6 +83,9 @@ pub struct SaveParams {
|
||||
/// JPEG quality 1..100 for lossy exports.
|
||||
#[serde(default)]
|
||||
pub quality: Option<u8>,
|
||||
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
|
||||
#[serde(default, rename = "tiffLayers")]
|
||||
pub tiff_layers: bool,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -112,29 +117,51 @@ pub struct ListParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RunParams {
|
||||
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussian`.
|
||||
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussianBlur`.
|
||||
pub id: String,
|
||||
/// Command parameters as a JSON object (see the `params` doc in `command_list`).
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
/// Wait for a long command (filters, Content-Aware Fill, Photomerge, …) to finish (default
|
||||
/// true). With false it runs as a background job and the result is `{"job": id}`: poll it
|
||||
/// with `jobs_list`, stop it with `jobs_cancel`.
|
||||
#[serde(default)]
|
||||
pub wait: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct JobCancelParams {
|
||||
/// The job id from `command_run` / `jobs_list` (omit to cancel every running job).
|
||||
#[serde(default)]
|
||||
pub job: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BatchParams {
|
||||
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`.
|
||||
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`. A
|
||||
/// step with `"wait": false` starts a long command as a background job (its result is
|
||||
/// `{"job": id}`, as in `command_run`); later steps that edit the same document fail until
|
||||
/// the job ends.
|
||||
pub steps: Vec<RunParams>,
|
||||
/// Stop at the first failing step (default true).
|
||||
#[serde(default)]
|
||||
pub stop_on_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// Strict: an argument the tool doesn't forward is an error, not silently dropped.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PointerParams {
|
||||
/// Events in document coordinates: `[{"kind":"down|move|up","x":..,"y":..,"pressure":..}]`.
|
||||
pub events: Vec<Value>,
|
||||
/// Modifier keys, e.g. `{"shift":true}`.
|
||||
/// Modifier keys, e.g. `{"shift":true}` (also `alt`, `command`, `ctrl`, `space`).
|
||||
#[serde(default)]
|
||||
pub modifiers: Option<Value>,
|
||||
/// Mouse button: `left` (default), or `right` / `secondary`: with the Move tool or
|
||||
/// `{"command":true}` it opens the canvas layer menu, with a painting tool the Brush Preset
|
||||
/// picker.
|
||||
#[serde(default)]
|
||||
pub button: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -145,7 +172,10 @@ pub struct MenuParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct UiSetParams {
|
||||
/// Fields accepted by the control method `ui.set` (tool, panels, zoom, center, dark).
|
||||
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, maskTarget,
|
||||
/// vectorMaskTarget, selectionMode, zoom, center, fit, theme (pro, proMedium, studio,
|
||||
/// studioLight, classic), brushSection, brushTab, brushesView, brushSize. Other fields are an
|
||||
/// error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -252,6 +282,7 @@ impl PhotocraftMcp {
|
||||
// of the document (`Session::edit`), so the session is still
|
||||
// consistent: keep serving instead of failing every later call.
|
||||
let mut g = h.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.sync_jobs();
|
||||
f(&mut g)
|
||||
})
|
||||
.await
|
||||
@@ -281,22 +312,24 @@ impl PhotocraftMcp {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Ok(fail(format!("app screenshot data: {error}"))),
|
||||
};
|
||||
if let Err(error) = check_png(bytes.len()) {
|
||||
return Ok(fail(error));
|
||||
}
|
||||
let bytes = match downscale_png(&bytes, max_side.unwrap_or(0)) {
|
||||
Ok(Some(small)) => small,
|
||||
Ok(None) => bytes,
|
||||
Err(error) => return Ok(fail(error)),
|
||||
};
|
||||
if let Err(error) = check_png(bytes.len()) {
|
||||
return Ok(fail(error));
|
||||
}
|
||||
Ok(png_result(&bytes, "screenshot of the live app window".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn downscale_png(png: &[u8], max_side: u32) -> Result<Option<Vec<u8>>, AutomationError> {
|
||||
// A compressed reply's byte limit does not bound its decoded pixel allocation.
|
||||
let opts =
|
||||
photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 } };
|
||||
let opts = photocraft_codecs::DecodeOptions {
|
||||
limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let img = photocraft_codecs::decode_as_with(photocraft_codecs::Format::Png, png, &opts).map_err(|error| AutomationError::Other(error.to_string()))?;
|
||||
let (w, h) = img.dimensions();
|
||||
if max_side == 0 || w.max(h) <= max_side {
|
||||
@@ -372,11 +405,11 @@ impl PhotocraftMcp {
|
||||
if let Some(v) = p.name {
|
||||
params.insert("name".into(), v.into());
|
||||
}
|
||||
self.run_command("file.new".into(), Value::Object(params)).await
|
||||
self.run_command("file.new".into(), Value::Object(params), true).await
|
||||
}
|
||||
|
||||
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold.")]
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. Returns warnings about anything the format cannot hold.")]
|
||||
async fn doc_save(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
|
||||
self.save_impl(p).await
|
||||
}
|
||||
@@ -462,9 +495,23 @@ impl PhotocraftMcp {
|
||||
Ok(ok_json(&Value::Array(items)))
|
||||
}
|
||||
|
||||
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result.")]
|
||||
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result. \
|
||||
Long commands (filters, Content-Aware Fill/Scale, Photomerge, brush import) wait to finish unless `wait` is false: \
|
||||
then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel).")]
|
||||
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))).await
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true)).await
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
|
||||
@@ -474,9 +521,11 @@ impl PhotocraftMcp {
|
||||
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
|
||||
}
|
||||
let stop = p.stop_on_error.unwrap_or(true);
|
||||
let steps: Vec<Value> = p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})).collect();
|
||||
let steps: Vec<Value> =
|
||||
p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({})), "wait": s.wait.unwrap_or(true)})).collect();
|
||||
let args = json!({"steps": steps, "stopOnError": stop});
|
||||
if let Some(r) = self.headless_op(move |h| h.batch(&args)).await {
|
||||
// The reply travels as escaped JSON text, so steps are charged their escaped size.
|
||||
if let Some(r) = self.headless_op(move |h| h.batch_with_budget(&args, BatchReplyBudget::escaped())).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
@@ -484,7 +533,7 @@ impl PhotocraftMcp {
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
let mut failed = 0;
|
||||
let mut reply_budget = BatchReplyBudget::default();
|
||||
let mut reply_budget = BatchReplyBudget::escaped();
|
||||
for s in &steps {
|
||||
let response = b.call("engine.execute", s.clone()).await;
|
||||
let was_error = response.is_err();
|
||||
@@ -510,7 +559,9 @@ impl PhotocraftMcp {
|
||||
|
||||
// ----- live-GUI tools (bridge mode) -----
|
||||
|
||||
#[tool(description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree).")]
|
||||
#[tool(
|
||||
description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, windows). For the menu tree, call `control_call` with method `ui.menu.list`."
|
||||
)]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
|
||||
@@ -526,7 +577,9 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Bridge mode: send pointer events (document coordinates) to the active tool.")]
|
||||
#[tool(
|
||||
description = "Bridge mode: send pointer events (document coordinates) to the active tool; with a Color Picker open they sample the image into it instead."
|
||||
)]
|
||||
async fn ui_pointer(&self, Parameters(p): Parameters<PointerParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => {
|
||||
@@ -534,6 +587,9 @@ impl PhotocraftMcp {
|
||||
if let Some(m) = p.modifiers {
|
||||
params["modifiers"] = m;
|
||||
}
|
||||
if let Some(button) = p.button {
|
||||
params["button"] = button.into();
|
||||
}
|
||||
to_result(b.call("ui.pointer", params).await)
|
||||
}
|
||||
None => bridge_only("ui_pointer"),
|
||||
@@ -548,7 +604,9 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Bridge mode: change UI state (tool, panels, zoom, center, dark).")]
|
||||
#[tool(
|
||||
description = "Bridge mode: change UI state (tool, panels, dock, zoom, center, fit, theme, brush settings; see `fields`). Unknown fields are an error."
|
||||
)]
|
||||
async fn ui_set(&self, Parameters(p): Parameters<UiSetParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.set", p.fields).await),
|
||||
@@ -570,15 +628,27 @@ fn bridge_only_headless(name: &str) -> Result<CallToolResult, McpError> {
|
||||
}
|
||||
|
||||
impl PhotocraftMcp {
|
||||
async fn run_command(&self, id: String, params: Value) -> Result<CallToolResult, McpError> {
|
||||
async fn run_command(&self, id: String, params: Value, wait: bool) -> Result<CallToolResult, McpError> {
|
||||
let (id2, params2) = (id.clone(), params.clone());
|
||||
if let Some(r) = self.headless_op(move |h| h.command_run(&id2, params2)).await {
|
||||
if let Some(r) = self.headless_op(move |h| h.command_start(&id2, params2, wait)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call("engine.execute", json!({"command": id, "params": params})).await)
|
||||
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> {
|
||||
let p2 = params.clone();
|
||||
if let Some(r) = self.headless_op(move |h| h.command_start(method, p2, true)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call(method, params).await)
|
||||
}
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
@@ -590,7 +660,7 @@ impl PhotocraftMcp {
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
}
|
||||
@@ -632,6 +702,7 @@ mod tests {
|
||||
fn bridge_screenshot_decode_limits_apply_even_without_downscaling() {
|
||||
let image = photocraft_codecs::Image::from_u8(8193, 1, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8193 * 4]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
assert!(downscale_png(b"not a png", 32).is_err());
|
||||
assert!(downscale_png(&png, 0).is_err());
|
||||
assert!(downscale_png(&png, 1024).is_err());
|
||||
}
|
||||
@@ -644,4 +715,48 @@ mod tests {
|
||||
let r = mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap();
|
||||
assert!(r.is_ok(), "session still usable after a panic: {r:?}");
|
||||
}
|
||||
|
||||
/// Regression (#504): the batch budget counted plain JSON, but MCP sends the reply as escaped
|
||||
/// text, so a batch the budget stopped could still exceed the tool-result ceiling and the
|
||||
/// client lost every step result.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_command_batch_stopped_by_the_budget_keeps_its_step_results() {
|
||||
let mcp = PhotocraftMcp::headless();
|
||||
let step = |id: &str, params: Value| RunParams { id: id.into(), params: Some(params), wait: None };
|
||||
let mut steps: Vec<RunParams> = (0..MAX_BATCH_STEPS - 1).map(|_| step("command.list", json!({}))).collect();
|
||||
steps.push(step("file.new", json!({"width": 8, "height": 8})));
|
||||
let result = mcp.command_batch(Parameters(BatchParams { steps, stop_on_error: Some(false) })).await.unwrap();
|
||||
assert_ne!(result.is_error, Some(true), "{:?}", result.content.first().and_then(|c| c.as_text()).map(|t| &t.text[..200.min(t.text.len())]));
|
||||
let text = &result.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
let reply: Value = serde_json::from_str(text).unwrap();
|
||||
let results = reply["results"].as_array().unwrap();
|
||||
assert!(results.last().unwrap()["error"].as_str().unwrap().contains("batch response budget exceeded"), "{}", results.last().unwrap());
|
||||
assert_eq!(reply["completed"].as_u64().unwrap() as usize, results.len() - 1);
|
||||
assert_eq!(reply["failed"], 1);
|
||||
let docs = mcp.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
|
||||
assert_eq!(docs, 0, "the step after the budget ran out did not run");
|
||||
}
|
||||
|
||||
/// The MCP server applies finished jobs before every tool call too (#503).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_tools_see_a_finished_background_job() {
|
||||
let mcp = PhotocraftMcp::headless();
|
||||
mcp.headless_op(|h| {
|
||||
h.command_run("file.new", json!({"width": 64, "height": 48}))?;
|
||||
h.command_run("filter.noise.addNoise", json!({"amount": 50}))?;
|
||||
h.command_start("filter.blur.gaussianBlur", json!({"radius": 4}), false)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
let inspected = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap();
|
||||
if inspected["history"].as_array().unwrap().last().unwrap() == "Gaussian Blur" {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Capability-based filesystem policy for untrusted automation paths.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -46,9 +46,8 @@ impl AuthorizedWorkspace {
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(|e| file_error("read", path, e))?;
|
||||
Ok(bytes)
|
||||
// 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))
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
@@ -154,6 +153,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
||||
let safe_file_command = matches!(
|
||||
id,
|
||||
"file.new"
|
||||
| "file.newFromClipboard"
|
||||
| "file.close"
|
||||
| "file.closeAll"
|
||||
| "file.closeOthers"
|
||||
@@ -180,6 +180,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
||||
| "layer.videoLayers.reloadFrame"
|
||||
| "image.applyDataSet"
|
||||
)
|
||||
|| command_uses_ambient_path(id, params)
|
||||
|| profile_command_may_read_ambient(id, params)
|
||||
|| preferences_may_grant_ambient_paths(id, params)
|
||||
|| params_contain_ambient_path(id, params)
|
||||
@@ -201,11 +202,23 @@ pub fn authorize_desktop_engine_command(id: &str, params: &Value) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`authorize_engine_command`] as the function pointer [`photocraft_engine::Session::authorize`]
|
||||
/// stores. `actions.play` calls it for every nested step.
|
||||
pub fn authorize_engine_step(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
authorize_engine_command(id, params).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`authorize_desktop_engine_command`] as a [`photocraft_engine::Session::authorize`] hook.
|
||||
pub fn authorize_desktop_engine_step(id: &str, params: &Value) -> photocraft_engine::Result<()> {
|
||||
authorize_desktop_engine_command(id, params).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
|
||||
let keys: &[&str] = match id {
|
||||
"image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"],
|
||||
"filter.distort.displace" => &["mapPath"],
|
||||
"layer.quickExportAsPng" | "layer.exportAs" => &["path"],
|
||||
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
|
||||
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
|
||||
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
|
||||
_ => &[],
|
||||
@@ -232,16 +245,29 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
|
||||
if id != "prefs.set" {
|
||||
return false;
|
||||
}
|
||||
let direct = params
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."));
|
||||
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| {
|
||||
values.keys().any(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."))
|
||||
});
|
||||
let direct = params.get("path").and_then(Value::as_str).is_some_and(preference_uses_ambient_filesystem);
|
||||
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| values.keys().any(|path| preference_uses_ambient_filesystem(path)));
|
||||
direct || batch
|
||||
}
|
||||
|
||||
fn preference_uses_ambient_filesystem(path: &str) -> bool {
|
||||
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
|
||||
// judge the first non-empty one; no segment at all is the whole-preferences update.
|
||||
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
|
||||
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
|
||||
}
|
||||
|
||||
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" => {
|
||||
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
|
||||
params.get("data").is_none() && params.get("path").is_some()
|
||||
}
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_path(value: &str) -> bool {
|
||||
value.contains('/')
|
||||
|| value.contains('\\')
|
||||
@@ -407,7 +433,11 @@ mod tests {
|
||||
#[test]
|
||||
fn filesystem_commands_fail_closed() {
|
||||
for id in [
|
||||
"file.open",
|
||||
"file.openAs",
|
||||
"file.save",
|
||||
"file.saveAs",
|
||||
"file.saveACopy",
|
||||
"file.export.saveForWebLegacy",
|
||||
"pattern.import",
|
||||
"layer.smartObjects.exportContents",
|
||||
@@ -419,10 +449,159 @@ mod tests {
|
||||
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
|
||||
}
|
||||
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
|
||||
// The UI-level examples in docs/control-protocol.md.
|
||||
for id in ["view.zoomIn", "window.theme.pro", "edit.search"] {
|
||||
assert!(authorize_desktop_engine_command(id, &serde_json::json!({})).is_ok(), "{id}");
|
||||
}
|
||||
assert!(authorize_engine_command("image.mode.cmyk", &serde_json::json!({})).is_ok());
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
|
||||
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
|
||||
assert!(authorize_engine_step("file.open", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_step("actions.play", &serde_json::json!({})).is_ok());
|
||||
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_rejects_ambient_path_commands_and_preferences() {
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
|
||||
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
|
||||
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
|
||||
("plugin.reload", serde_json::json!({})),
|
||||
("plugin.install", serde_json::json!({"path": " "})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}");
|
||||
}
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
|
||||
("plugin.install", serde_json::json!({"data": "QUJD"})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}");
|
||||
}
|
||||
for path in [
|
||||
"",
|
||||
"colorSettings",
|
||||
"colorSettings.workingRgb",
|
||||
"scriptEvents",
|
||||
"scriptEvents.enabled",
|
||||
".scriptEvents",
|
||||
".scriptEvents.enabled",
|
||||
"historyLog.filePath",
|
||||
".historyLog.filePath",
|
||||
"plugIns.additionalPluginsFolder",
|
||||
"scratchDisks.disks",
|
||||
".",
|
||||
"..historyLog.filePath",
|
||||
] {
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": path, "value": {}})).is_err(), "{path}");
|
||||
}
|
||||
assert!(
|
||||
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
|
||||
);
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_filesystem_path_params_are_classified() {
|
||||
let probe = serde_json::json!({
|
||||
"path": "/outside/photocraft-probe",
|
||||
"file": "/outside/photocraft-probe.icc",
|
||||
"mapPath": "/outside/photocraft-probe.png",
|
||||
"profile": "/outside/photocraft-probe.icc",
|
||||
"workingRgb": "/outside/photocraft-probe.icc",
|
||||
"workingCmyk": "/outside/photocraft-probe.icc",
|
||||
"workingGray": "/outside/photocraft-probe.icc",
|
||||
"input": "/outside/photocraft-input",
|
||||
"output": "/outside/photocraft-output",
|
||||
"paths": ["/outside/photocraft-probe.psd"],
|
||||
});
|
||||
let preference_probe = serde_json::json!({"path": ".scriptEvents", "value": {}});
|
||||
let unclassified: Vec<_> = photocraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|spec| documents_filesystem_path_params(spec.id, spec.params))
|
||||
.filter(|spec| {
|
||||
let params = if spec.id == "prefs.set" { &preference_probe } else { &probe };
|
||||
authorize_engine_command(spec.id, params).is_ok()
|
||||
})
|
||||
.map(|spec| spec.id)
|
||||
.collect();
|
||||
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
|
||||
}
|
||||
|
||||
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
if matches!(id, "prefs.get" | "prefs.reset") {
|
||||
return false;
|
||||
}
|
||||
// These registry descriptions refer to document vector paths, not host files.
|
||||
if matches!(
|
||||
id,
|
||||
"filter.blurGallery.pathBlur"
|
||||
| "filter.render.flame"
|
||||
| "shape.create"
|
||||
| "shape.edit"
|
||||
| "shape.info"
|
||||
| "shape.presets.list"
|
||||
| "shape.presets.new"
|
||||
| "path.list"
|
||||
| "path.info"
|
||||
| "path.set"
|
||||
| "path.transform"
|
||||
| "path.clippingPath.set"
|
||||
| "path.rename"
|
||||
| "select.toWorkPath"
|
||||
| "layer.vectorMask.add"
|
||||
| "layer.vectorMask.edit"
|
||||
| "layer.vectorMask.info"
|
||||
| "paint.symmetryFromPath"
|
||||
| "edit.defineCustomShape"
|
||||
| "layer.combineShapes.unite"
|
||||
| "layer.combineShapes.subtractFrontShape"
|
||||
| "layer.combineShapes.intersectShapeAreas"
|
||||
| "layer.combineShapes.excludeOverlappingShapes"
|
||||
| "layer.combineShapes.mergeShapeComponents"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
params.to_ascii_lowercase().contains("path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_preferences_update_cannot_enable_automation_script_events() {
|
||||
let mut headless = crate::headless::Headless::new();
|
||||
let result = headless.command_run(
|
||||
"prefs.set",
|
||||
serde_json::json!({
|
||||
"path": "",
|
||||
"value": {
|
||||
"scriptEvents": {
|
||||
"enabled": true,
|
||||
"bindings": [{
|
||||
"event": "newDocument",
|
||||
"steps": [["file.saveACopy", {"path": "/outside/canary.psd"}]]
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
|
||||
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
|
||||
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
|
||||
("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})),
|
||||
("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})),
|
||||
] {
|
||||
assert!(headless.command_run(id, params).is_err(), "{id}");
|
||||
}
|
||||
headless.command_run("file.new", serde_json::json!({"width": 5, "height": 5})).unwrap();
|
||||
assert!(headless.session.file_menu.event_log.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -214,3 +214,119 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Click just after "Big", insert a word, then bold through the end of the line. Geometry comes
|
||||
/// from `type.hitTest` / `type.caret` / `type.navigate`; the edit is the existing `type.edit`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_edits_text_by_position() {
|
||||
let dir = tmp("type-caret");
|
||||
let c = connect(&dir).await;
|
||||
tool(&c, "doc_new", json!({"width": 480, "height": 200, "background": "white"})).await;
|
||||
let listed = tool(&c, "command_list", json!({"filter": "type."})).await;
|
||||
let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
for id in ["type.hitTest", "type.caret", "type.navigate"] {
|
||||
assert!(ids.contains(&id), "command_list missing {id}: {ids:?}");
|
||||
}
|
||||
|
||||
run(&c, "type.create", json!({"x": 40, "y": 90, "text": "Big Sale", "size": 48})).await;
|
||||
let before = inspect(&c).await["history"].as_array().unwrap().len();
|
||||
// The caret after "Big" and the caret after the following space. A quarter of the way is
|
||||
// the left half of that space, which hit-tests as the caret after "Big".
|
||||
let after_big = run(&c, "type.caret", json!({"index": 3})).await;
|
||||
let after_space = run(&c, "type.caret", json!({"index": 4})).await;
|
||||
let seg = after_big["segment"].as_array().unwrap();
|
||||
let (x3, y0, y1) = (seg[0][0].as_f64().unwrap(), seg[0][1].as_f64().unwrap(), seg[1][1].as_f64().unwrap());
|
||||
let x4 = after_space["segment"][0][0].as_f64().unwrap();
|
||||
assert!(x4 > x3 + 0.5, "space should have width, caret x {x3} -> {x4}");
|
||||
let hit = run(&c, "type.hitTest", json!({"x": x3 + 0.25 * (x4 - x3), "y": (y0 + y1) / 2.0})).await;
|
||||
assert_eq!(hit["index"].as_u64(), Some(3), "{hit}");
|
||||
assert_eq!(hit["inside"], true, "{hit}");
|
||||
let quiet = inspect(&c).await["history"].as_array().unwrap().len();
|
||||
assert_eq!(quiet, before, "caret queries are not history steps");
|
||||
|
||||
run(&c, "type.edit", json!({"replace": {"start": hit["index"], "end": hit["index"], "text": " Summer"}})).await;
|
||||
let info = run(&c, "type.info", json!({})).await;
|
||||
assert_eq!(info["text"], "Big Summer Sale", "{info}");
|
||||
|
||||
let end = run(&c, "type.navigate", json!({"index": 0, "move": "lineEnd"})).await;
|
||||
let n = info["text"].as_str().unwrap().chars().count() as u64;
|
||||
assert_eq!(end["index"].as_u64(), Some(n), "{end}");
|
||||
// "Summer" starts one character after the insertion caret (the space in " Summer").
|
||||
let summer = hit["index"].as_u64().unwrap() + 1;
|
||||
run(&c, "type.setStyle", json!({"range": [summer, end["index"]], "fauxBold": true})).await;
|
||||
let info = run(&c, "type.info", json!({})).await;
|
||||
assert_eq!(info["text"], "Big Summer Sale");
|
||||
let bold = info["runs"].as_array().unwrap().iter().find(|r| r["style"]["faux_bold"] == true);
|
||||
let bold = bold.unwrap_or_else(|| panic!("no bold run in {info}"));
|
||||
assert_eq!(bold["start"].as_u64(), Some(summer), "{bold}");
|
||||
assert_eq!(bold["end"].as_u64(), end["index"].as_u64(), "{bold}");
|
||||
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
async fn connect_headless(headless: photocraft_automation::Headless) -> Conn {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
tokio::spawn(async move {
|
||||
let backend = photocraft_automation::Backend::Headless(std::sync::Arc::new(std::sync::Mutex::new(headless)));
|
||||
if let Ok(running) = PhotocraftMcp::with_backend(backend).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
Client.serve(c).await.expect("client init")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_records_and_replays_an_action() {
|
||||
let dir = tmp("actions");
|
||||
let c = connect(&dir).await;
|
||||
let listed = tool(&c, "command_list", json!({"filter": "actions."})).await;
|
||||
let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|item| item.get("id").and_then(Value::as_str)).collect();
|
||||
for id in ["actions.list", "actions.get", "actions.record", "actions.stop", "actions.play", "actions.delete"] {
|
||||
assert!(ids.contains(&id), "{id} missing from {ids:?}");
|
||||
}
|
||||
|
||||
run(&c, "file.new", json!({"width": 32, "height": 32, "background": "white"})).await;
|
||||
run(&c, "actions.record", json!({"name": "Red"})).await;
|
||||
run(&c, "layer.new.layer", json!({})).await;
|
||||
run(&c, "select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
let stopped = run(&c, "actions.stop", json!({})).await;
|
||||
assert_eq!(stopped["steps"], 3, "{stopped}");
|
||||
let got = run(&c, "actions.get", json!({"action": "Red"})).await;
|
||||
assert_eq!(got["steps"][0][0], "layer.new.layer");
|
||||
assert_eq!(got["steps"][1][0], "select.rect");
|
||||
assert_eq!(got["steps"][2][0], "edit.fill");
|
||||
|
||||
run(&c, "file.new", json!({"width": 32, "height": 32, "background": "white"})).await;
|
||||
let played = run(&c, "actions.play", json!({"action": "Red"})).await;
|
||||
assert_eq!(played["ran"], 3, "{played}");
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let px = pixel(&c, 2, 2).await;
|
||||
assert!(close(&px, &[1.0, 0.0, 0.0, 1.0], 1e-4), "{px:?}");
|
||||
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_play_refuses_a_recorded_file_open() {
|
||||
let dir = tmp("actions-auth");
|
||||
let workspace = AuthorizedWorkspace::new(Some(&dir), Some(&dir)).expect("test workspace");
|
||||
let mut headless = photocraft_automation::Headless::with_workspace(workspace);
|
||||
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!({}))],
|
||||
});
|
||||
let c = connect_headless(headless).await;
|
||||
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
|
||||
assert_eq!(played["ran"], 0, "{played}");
|
||||
assert_eq!(played["failed"]["step"], 0, "{played}");
|
||||
assert_eq!(played["failed"]["id"], "file.open", "{played}");
|
||||
assert!(played["failed"]["error"].as_str().unwrap_or("").contains("disabled"), "{played}");
|
||||
let session = tool(&c, "session_list", json!({})).await;
|
||||
assert!(session["documents"].as_array().is_some_and(|d| d.is_empty()), "{session}");
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -169,6 +169,39 @@ async fn command_list_filters() {
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
/// The backticked examples in every `id` property description of a schema.
|
||||
fn id_examples(schema: &Value, out: &mut Vec<String>) {
|
||||
match schema {
|
||||
Value::Object(m) => {
|
||||
if let Some(d) = m.get("properties").and_then(|p| p["id"]["description"].as_str()) {
|
||||
out.extend(d.split('`').skip(1).step_by(2).map(str::to_owned));
|
||||
}
|
||||
m.values().for_each(|v| id_examples(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| id_examples(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_ids_in_tool_schemas_exist() {
|
||||
// Agents copy these examples verbatim (#378).
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let all = json_of(&call(&client, "command_list", json!({})).await);
|
||||
let ids: Vec<&str> = all.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
for name in ["command_run", "command_batch"] {
|
||||
let tool = tools.iter().find(|t| t.name == name).unwrap();
|
||||
let mut examples = Vec::new();
|
||||
id_examples(&Value::Object((*tool.input_schema).clone()), &mut examples);
|
||||
assert!(!examples.is_empty(), "{name}: no example ids");
|
||||
for id in examples {
|
||||
assert!(ids.contains(&id.as_str()), "{name}: `{id}` is not a registered command");
|
||||
}
|
||||
}
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn errors_are_tool_errors_not_crashes() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
@@ -254,6 +287,86 @@ async fn open_png_and_inspect() {
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// #518, #523: `doc_open` returns why an opened image is incomplete.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_reports_decode_warnings() {
|
||||
let dir = tmp("open-warnings");
|
||||
let jpeg = write_image(&dir, "whole.jpg", photocraft_codecs::Format::Jpeg);
|
||||
std::fs::write(dir.join("cut.jpg"), &jpeg[..jpeg.len() - 4]).unwrap();
|
||||
// Two 1x1 frames.
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||||
for _ in 0..2 {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
std::fs::write(dir.join("anim.gif"), gif).unwrap();
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
let warnings = async |path: &str| json_of(&call(&client, "doc_open", json!({"path": path})).await)["warnings"].clone();
|
||||
assert_eq!(warnings("whole.jpg").await, json!([]));
|
||||
assert_eq!(warnings("cut.jpg").await, json!(["JPEG data ends early (the file is truncated or damaged); part of the image is missing"]));
|
||||
assert_eq!(warnings("anim.gif").await, json!(["only the first of 2 frames was imported"]));
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
fn write_image(dir: &std::path::Path, name: &str, format: photocraft_codecs::Format) -> Vec<u8> {
|
||||
let img = photocraft_codecs::Image::from_u8(16, 8, photocraft_codecs::ChannelLayout::Rgb, (0..384).map(|i| (i * 7 % 251) as u8).collect()).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, format, &Default::default()).unwrap();
|
||||
std::fs::write(dir.join(name), &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
/// A save without `path` writes back only to a layered file in its own format (#416).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn save_without_path_never_flattens_over_the_opened_file() {
|
||||
let dir = tmp("save-in-place");
|
||||
let png = write_image(&dir, "seed.png", photocraft_codecs::Format::Png);
|
||||
let jpg = write_image(&dir, "seed.jpg", photocraft_codecs::Format::Jpeg);
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
let refused = |r: &CallToolResult| r.is_error == Some(true) && text(r).contains("pass `path`");
|
||||
|
||||
// A flat file, edited or not, is left unchanged.
|
||||
json_of(&call(&client, "doc_open", json!({"path": "seed.png"})).await);
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.curves", "params": {"points": [[0, 0], [128, 170], [255, 255]]}})).await);
|
||||
let r = call(&client, "doc_save", json!({})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("seed.png")).unwrap(), png);
|
||||
json_of(&call(&client, "doc_open", json!({"path": "seed.jpg"})).await);
|
||||
let r = call(&client, "doc_save", json!({})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("seed.jpg")).unwrap(), jpg);
|
||||
|
||||
// An explicit path, even the opened file's own, still writes and reports what was lost.
|
||||
let r = json_of(&call(&client, "doc_save", json!({"path": "seed.jpg"})).await);
|
||||
assert!(r["warnings"].to_string().contains("lossy"), "{r}");
|
||||
|
||||
// A layered file saves in place in its own format, but not converted over itself.
|
||||
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
|
||||
json_of(&call(&client, "doc_save", json!({"path": "layered.psd"})).await);
|
||||
json_of(&call(&client, "doc_open", json!({"path": "layered.psd"})).await);
|
||||
let psd = std::fs::read(dir.join("layered.psd")).unwrap();
|
||||
let r = call(&client, "doc_save", json!({"format": "png"})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("layered.psd")).unwrap(), psd);
|
||||
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "layered.psd");
|
||||
assert!(photocraft_io::is_psd(&std::fs::read(dir.join("layered.psd")).unwrap()));
|
||||
|
||||
// Once saved as .pcraft, a flat-born document saves in place there.
|
||||
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
|
||||
json_of(&call(&client, "doc_save", json!({"path": "work.pcraft"})).await);
|
||||
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "work.pcraft");
|
||||
assert_eq!(std::fs::read(dir.join("seed.png")).unwrap(), png);
|
||||
|
||||
// A template opens untitled, without its path.
|
||||
std::fs::write(dir.join("card.psdt"), &psd).unwrap();
|
||||
assert_eq!(json_of(&call(&client, "doc_open", json!({"path": "card.psdt"})).await)["name"], "Untitled-1");
|
||||
let r = call(&client, "doc_save", json!({})).await;
|
||||
assert!(refused(&r), "{}", text(&r));
|
||||
assert_eq!(std::fs::read(dir.join("card.psdt")).unwrap(), psd);
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects() {
|
||||
let base = tmp("filesystem-policy");
|
||||
@@ -293,6 +406,10 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
fake_app_with_screenshot(None).await
|
||||
}
|
||||
|
||||
async fn fake_app_with_screenshot(screenshot_png: Option<Vec<u8>>) -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let h = tokio::spawn(async move {
|
||||
@@ -317,14 +434,17 @@ async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
json!({"id": id, "ok": true, "result": {"tool": "brush", "panels": ["layers"]}})
|
||||
}
|
||||
"engine.execute" => {
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"]}})
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"], "wait": req["params"]["wait"]}})
|
||||
}
|
||||
"engine.commands" => {
|
||||
json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]})
|
||||
}
|
||||
"ui.pointer" => json!({"id": id, "ok": true, "result": req["params"]}),
|
||||
"ui.screenshot" => {
|
||||
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let bytes = screenshot_png.clone().unwrap_or_else(|| {
|
||||
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
});
|
||||
let png = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
json!({"id": id, "ok": true, "result": {"mimeType": "image/png", "base64": png}})
|
||||
}
|
||||
@@ -368,6 +488,44 @@ async fn bridge_forwards_to_control_protocol() {
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_previews_downscale_before_enforcing_the_png_budget() {
|
||||
use photocraft_automation::budgets::MAX_PNG_BYTES;
|
||||
|
||||
let (width, height) = (1400, 1000);
|
||||
let mut pixels = vec![0; width * height * 4];
|
||||
let mut state = 0x6d2b_79f5_u32;
|
||||
for pixel in &mut pixels {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
*pixel = state as u8;
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgba, pixels).unwrap();
|
||||
let source_png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
assert!(source_png.len() > MAX_PNG_BYTES, "fixture must exceed the PNG budget");
|
||||
|
||||
let (addr, app) = fake_app_with_screenshot(Some(source_png)).await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
|
||||
for (tool, args) in [("ui_screenshot", json!({"max_side": 32})), ("doc_render_preview", json!({"max_side": 32}))] {
|
||||
let reply = call(&client, tool, args).await;
|
||||
assert_ne!(reply.is_error, Some(true), "{tool}: {}", text(&reply));
|
||||
let img = reply.content.iter().find_map(|content| content.as_image()).expect("preview image");
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap();
|
||||
assert!(png.len() <= MAX_PNG_BYTES, "{tool} returned {} bytes", png.len());
|
||||
let decoded = photocraft_codecs::decode(&png).unwrap();
|
||||
assert_eq!(decoded.dimensions(), (32, 22), "{tool}");
|
||||
}
|
||||
|
||||
let full_size = call(&client, "ui_screenshot", json!({})).await;
|
||||
assert_eq!(full_size.is_error, Some(true));
|
||||
assert!(text(&full_size).contains("automation PNG exceeds"), "{}", text(&full_size));
|
||||
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_rejects_non_loopback() {
|
||||
assert!(PhotocraftMcp::bridge("10.0.0.5:7878", CONTROL_TOKEN).is_err());
|
||||
@@ -421,6 +579,97 @@ async fn command_batch_runs_steps_in_order() {
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_step_without_waiting_starts_a_background_job() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 600, "height": 400})).await);
|
||||
let blur = json!({"id": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false});
|
||||
let r = json_of(
|
||||
&call(&client, "command_batch", json!({"steps": [{"id": "layer.new.layer"}, {"id": "edit.fill", "params": {"color": "#808080"}}, blur]})).await,
|
||||
);
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let started = &r["results"][2]["result"];
|
||||
assert_eq!(started["pending"], true, "the step returned without waiting: {r}");
|
||||
let job = started["job"].as_u64().expect("a job id");
|
||||
// The job is listed and finishes like one started by command_run.
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
let l = json_of(&call(&client, "jobs_list", json!({})).await);
|
||||
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
|
||||
assert!(state.is_some(), "job {job} not listed: {l}");
|
||||
if state == Some(json!("done")) {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed().as_secs() < 60, "{l}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
// Without `wait` a step still returns the command's own result.
|
||||
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "params": {"radius": 2}}]})).await);
|
||||
assert!(r["results"][0]["result"]["filter"].is_object(), "{r}");
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_command_batch_forwards_each_steps_wait() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}]})).await);
|
||||
assert_eq!(r["completed"], 2, "{r}");
|
||||
assert_eq!(r["results"][0]["result"]["wait"], false, "{r}");
|
||||
assert_eq!(r["results"][1]["result"]["wait"], true, "{r}");
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// #514: `ui_pointer` forwards `button` (a right-click opens the layer menu or the Brush Preset
|
||||
/// picker) and rejects arguments it doesn't forward instead of dropping them.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_ui_pointer_forwards_the_button_and_rejects_unknown_arguments() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let events = json!([{"kind": "down", "x": 5, "y": 6}, {"kind": "up", "x": 5, "y": 6}]);
|
||||
let sent = json_of(&call(&client, "ui_pointer", json!({"events": events, "button": "right", "modifiers": {"command": true}})).await);
|
||||
assert_eq!(sent, json!({"events": events, "button": "right", "modifiers": {"command": true}}));
|
||||
let sent = json_of(&call(&client, "ui_pointer", json!({"events": events})).await);
|
||||
assert_eq!(sent, json!({"events": events}), "unset fields are not sent");
|
||||
for bad in [json!({"events": events, "buton": "right"}), json!({"events": events, "space": true})] {
|
||||
let Value::Object(args) = bad.clone() else { unreachable!() };
|
||||
let r = client.call_tool(CallToolRequestParams::new("ui_pointer").with_arguments(args)).await;
|
||||
assert!(!r.as_ref().is_ok_and(|r| r.is_error != Some(true)), "{bad} accepted: {r:?}");
|
||||
}
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
let schema = &tools.iter().find(|t| t.name == "ui_pointer").unwrap().input_schema;
|
||||
assert_eq!(schema.get("additionalProperties"), Some(&json!(false)), "{schema:?}");
|
||||
assert!(schema.get("properties").and_then(|p| p.get("button")).is_some(), "{schema:?}");
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// #368: agents can open the clipboard as a document of its own.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn new_from_clipboard_opens_the_copy_as_a_document() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
|
||||
let r = json_of(
|
||||
&call(
|
||||
&client,
|
||||
"command_batch",
|
||||
json!({"steps": [
|
||||
{"id": "select.rect", "params": {"x": 5, "y": 5, "width": 12, "height": 7}},
|
||||
{"id": "edit.copy"},
|
||||
{"id": "file.newFromClipboard"}
|
||||
]}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let doc = json_of(&call(&client, "doc_inspect", json!({})).await);
|
||||
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(12), Some(7)), "{doc}");
|
||||
let sess = json_of(&call(&client, "session_list", json!({})).await);
|
||||
assert_eq!(sess["documents"].as_array().unwrap().len(), 2);
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_rejects_too_many_steps() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
|
||||
@@ -22,6 +22,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
|
||||
* `transform::cached(src, dst, opts)`: process-wide transform cache.
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in
|
||||
sRGB. `content_hash()` compares bytes and suits cache keys only.
|
||||
|
||||
### Evaluation
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ fn err<T>(msg: impl Into<String>) -> Result<T, LutError> {
|
||||
Err(LutError(msg.into()))
|
||||
}
|
||||
|
||||
fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> {
|
||||
if !(2..=MAX_SIZE).contains(&size) {
|
||||
return err(format!("unsupported LUT size {size}"));
|
||||
}
|
||||
let entries = size.checked_pow(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
|
||||
let data_len = entries.checked_mul(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
|
||||
Ok((entries, data_len))
|
||||
}
|
||||
|
||||
impl LutFile {
|
||||
/// The identity table of edge `size`.
|
||||
pub fn identity(size: usize) -> Self {
|
||||
@@ -56,11 +65,9 @@ impl LutFile {
|
||||
}
|
||||
|
||||
fn check(self) -> Result<Self, LutError> {
|
||||
if self.size < 2 || self.size > MAX_SIZE {
|
||||
return err(format!("unsupported LUT size {}", self.size));
|
||||
}
|
||||
if self.data.len() != self.size.pow(3) * 3 {
|
||||
return err(format!("expected {} entries, found {}", self.size.pow(3), self.data.len() / 3));
|
||||
let (entries, data_len) = checked_data_len(self.size)?;
|
||||
if self.data.len() != data_len {
|
||||
return err(format!("expected {entries} entries, found {}", self.data.len() / 3));
|
||||
}
|
||||
if self.data.iter().any(|v| !v.is_finite()) {
|
||||
return err("non-finite LUT value");
|
||||
@@ -128,7 +135,11 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
||||
if size3 > 0 {
|
||||
return LutFile { title, size: size3, data: rows }.check();
|
||||
}
|
||||
if size1 >= 2 && rows.len() == size1 * 3 {
|
||||
if size1 >= 2 {
|
||||
let expected_rows = size1.checked_mul(3).ok_or_else(|| LutError(format!("unsupported 1D LUT size {size1}")))?;
|
||||
if rows.len() != expected_rows {
|
||||
return err("no LUT_3D_SIZE in .cube file");
|
||||
}
|
||||
let curve = |ch: usize, v: f32| {
|
||||
let x = v.clamp(0.0, 1.0) * (size1 - 1) as f32;
|
||||
let i = (x.floor() as usize).min(size1 - 2);
|
||||
@@ -184,12 +195,13 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
|
||||
Some(text[start..end].trim().trim_matches('"').trim().to_string())
|
||||
};
|
||||
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
|
||||
let (entries, _) = checked_data_len(size)?;
|
||||
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
|
||||
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
|
||||
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
|
||||
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let n3 = size.pow(3);
|
||||
let data = if floats.len() == n3 * 4 { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
|
||||
let rgba_entries = entries.checked_mul(4).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
|
||||
let data = if floats.len() == rgba_entries { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
|
||||
LutFile { title: tag("title").unwrap_or_default(), size, data }.check()
|
||||
}
|
||||
|
||||
@@ -292,6 +304,19 @@ mod tests {
|
||||
assert_eq!(l.size, 33);
|
||||
let last = &l.data[l.data.len() - 3..];
|
||||
assert!((last[0] - 0.5).abs() < 1e-6 && (last[1] - 1.0).abs() < 1e-6);
|
||||
|
||||
let mut larger = String::from("LUT_1D_SIZE 256\n");
|
||||
for i in 0..256 {
|
||||
let value = i as f32 / 255.0;
|
||||
larger.push_str(&format!("{value} {value} {value}\n"));
|
||||
}
|
||||
assert_eq!(parse_cube(&larger).unwrap().size, 33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_1d_size_multiplication_overflow_is_rejected() {
|
||||
let text = format!("LUT_1D_SIZE {}\n", usize::MAX);
|
||||
assert!(parse_cube(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,6 +342,12 @@ mod tests {
|
||||
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_3d_size_exponent_overflow_is_rejected() {
|
||||
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
|
||||
assert!(parse_look(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rejected() {
|
||||
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
|
||||
|
||||
@@ -147,6 +147,11 @@ pub struct Lut {
|
||||
pub stages: Vec<Stage>,
|
||||
}
|
||||
|
||||
/// Largest colour difference (CIE ΔE*ab) for which [`Profile::same_colors`] treats two profiles
|
||||
/// as one colour space: invisible, yet 20× what re-encoding a profile costs (sRGB as v2 tables
|
||||
/// against v4 parametric curves differs by 0.025). Moving sRGB's red primary 2% costs 1.0.
|
||||
pub const SAME_COLORS_MAX_DELTA_E: f64 = 0.5;
|
||||
|
||||
/// A parsed ICC profile.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Profile {
|
||||
@@ -216,6 +221,43 @@ impl Profile {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` describe the same colours: same colour space, and every sample
|
||||
/// of a device-value grid has the same colour through both (relative colorimetric, within
|
||||
/// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the
|
||||
/// sRGB IEC61966-2.1 Photoshop embeds (v2, 1024-entry tables) and the built-in sRGB (v4,
|
||||
/// parametric curves) differ in every byte. Colours are compared rather than round-tripped
|
||||
/// because CMYK → PCS → CMYK is not an identity even through a single profile.
|
||||
pub fn same_colors(&self, other: &Profile) -> bool {
|
||||
if self.content_hash() == other.content_hash() {
|
||||
return true;
|
||||
}
|
||||
let n = self.channels();
|
||||
if self.color_space != other.color_space || n == 0 || n > 4 {
|
||||
return false;
|
||||
}
|
||||
let lab = crate::builtin::Builtin::LabD50.profile();
|
||||
let to_lab = |p: &Profile| crate::transform::Transform::new(p, lab, Intent::RelativeColorimetric, false);
|
||||
let (Ok(a), Ok(b)) = (to_lab(self), to_lab(other)) else {
|
||||
return false;
|
||||
};
|
||||
// 17 steps per channel catch a TRC that differs only in its toe; 9 keep CMYK at 6561 samples.
|
||||
let steps: usize = if n <= 3 { 17 } else { 9 };
|
||||
// Lab D50 is the PCS, normalised as in ICC v4.
|
||||
let decode = |o: [f32; 3]| [f64::from(o[0]) * 100.0, f64::from(o[1]) * 255.0 - 128.0, f64::from(o[2]) * 255.0 - 128.0];
|
||||
let mut input = [0f32; 4];
|
||||
(0..steps.pow(n as u32)).all(|i| {
|
||||
let mut k = i;
|
||||
for v in input.iter_mut().take(n) {
|
||||
*v = (k % steps) as f32 / (steps - 1) as f32;
|
||||
k /= steps;
|
||||
}
|
||||
let (mut la, mut lb) = ([0f32; 3], [0f32; 3]);
|
||||
a.eval(&input[..n], &mut la);
|
||||
b.eval(&input[..n], &mut lb);
|
||||
math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E
|
||||
})
|
||||
}
|
||||
|
||||
/// Media white point used for ICC-absolute colorimetry: `wtpt`, except for v2 display
|
||||
/// profiles whose `wtpt` records the (unadapted) display white; those are treated as D50.
|
||||
pub fn media_white(&self) -> [f64; 3] {
|
||||
|
||||
@@ -590,3 +590,52 @@ fn gray_profile_with_reversed_lut_type_reencodes() {
|
||||
odd.description = "re-encoded".into();
|
||||
let _ = Profile::parse(&odd.with_encoded_bytes().to_bytes()).unwrap();
|
||||
}
|
||||
|
||||
/// sRGB the way Photoshop embeds it: a v2 profile whose TRCs are 1024-entry 16-bit tables.
|
||||
fn srgb_as_v2_tables() -> Profile {
|
||||
let mut p = srgb().clone();
|
||||
let table = |c: &photocraft_cms::Curve| {
|
||||
photocraft_cms::Curve::Table((0..1024).map(|i| ((c.eval64(i as f64 / 1023.0) * 65535.0).round() / 65535.0) as f32).collect())
|
||||
};
|
||||
let trc = p.trc.clone().unwrap();
|
||||
p.trc = Some([table(&trc[0]), table(&trc[1]), table(&trc[2])]);
|
||||
p.version = (2, 0x10);
|
||||
p.with_encoded_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_colors_ignores_the_encoding() {
|
||||
let v2 = Profile::parse(&srgb_as_v2_tables().to_bytes()).unwrap();
|
||||
assert_ne!(v2.content_hash(), srgb().content_hash(), "different bytes");
|
||||
assert!(v2.same_colors(srgb()) && srgb().same_colors(&v2));
|
||||
for b in Builtin::ALL {
|
||||
assert!(b.profile().same_colors(b.profile()), "{b:?}");
|
||||
// Re-encoded (fresh bytes, same model): CMYK too, where a round trip is not an identity.
|
||||
let again = Profile::parse(&b.profile().clone().with_encoded_bytes().to_bytes()).unwrap();
|
||||
assert!(again.same_colors(b.profile()), "{b:?} re-encoded");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_colors_tells_different_spaces_apart() {
|
||||
for other in [Builtin::DisplayP3, Builtin::LinearSrgb, Builtin::AdobeRgbCompat, Builtin::Rec2020, Builtin::CoatedCmyk, Builtin::GrayGamma22] {
|
||||
assert!(!srgb().same_colors(other.profile()), "{other:?}");
|
||||
}
|
||||
assert!(!Builtin::GrayGamma22.profile().same_colors(Builtin::SGray.profile()));
|
||||
// Scaling the red primary's XYZ by 2% makes a different space too.
|
||||
let mut moved = srgb().clone();
|
||||
let m = moved.matrix.as_mut().unwrap();
|
||||
for row in m.iter_mut() {
|
||||
row[0] *= 1.02;
|
||||
}
|
||||
assert!(!moved.with_encoded_bytes().same_colors(srgb()));
|
||||
}
|
||||
|
||||
/// The sRGB IEC61966-2.1 profile Windows ships (the one Photoshop embeds), when present.
|
||||
#[test]
|
||||
fn windows_srgb_is_the_builtin_srgb() {
|
||||
let Ok(bytes) = std::fs::read("C:/Windows/System32/spool/drivers/color/sRGB Color Space Profile.icm") else { return };
|
||||
let p = Profile::parse(&bytes).unwrap();
|
||||
assert_ne!(p.content_hash(), srgb().content_hash());
|
||||
assert!(p.same_colors(srgb()));
|
||||
}
|
||||
@@ -13,6 +13,10 @@ default = []
|
||||
# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF
|
||||
# stays read-unsupported and therefore asymmetric; it is off by default.
|
||||
avif = ["image/avif"]
|
||||
# HEIC/HEIF decode via the optional photocraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time; official builds and CI enable it (`--features heif` on the
|
||||
# apps). Without it HEIF files are still recognised and opening one is a clear "unsupported" error.
|
||||
heif = ["dep:photocraft-heif"]
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
@@ -26,6 +30,11 @@ image-webp = "0.2"
|
||||
zune-jpeg = "0.5"
|
||||
zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
photocraft-heif = { path = "../heif", optional = true }
|
||||
|
||||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
+32
-5
@@ -15,7 +15,8 @@ It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
|
||||
* **Honest.** `caps(format)` says what each format holds, and
|
||||
`fidelity_warnings(&image, format)` lists what an export will lose before you write it. Both
|
||||
functions use the same encode plan as `encode`, so a warning appears exactly when the file
|
||||
changes.
|
||||
changes. On the way in, `Image::warnings` lists what the decoded image doesn't show: frames or
|
||||
pages left out, or data that ended early.
|
||||
|
||||
```rust
|
||||
use photocraft_codecs::*;
|
||||
@@ -44,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
"Native" means the data is stored and read back without conversion. Anything else is converted by
|
||||
the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
|
||||
@@ -64,7 +66,17 @@ the same.
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate
|
||||
behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose).
|
||||
Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF
|
||||
support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`):
|
||||
single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The
|
||||
container's `irot`/`imir`/`clap` are applied and EXIF Orientation is rewritten to 1 (HEIF's EXIF
|
||||
tag only repeats the container's, and applying both would turn the photo twice). Grayscale
|
||||
decodes to RGB. Image sequences, overlays and identity derivations, multilayer HEVC and some
|
||||
4:2:2/4:4:4 streams return `CodecError::Unsupported` or `Malformed`, never wrong pixels. Writing
|
||||
needs an HEVC encoder and every mature one is C, so HEIF is listed in `ASYMMETRIC_EXCEPTIONS`.
|
||||
* **AVIF.** Encoding uses `ravif`, which is pure Rust. Decoding
|
||||
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
|
||||
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
|
||||
so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`.
|
||||
@@ -73,12 +85,27 @@ the same.
|
||||
files.
|
||||
* **Animation and multi-page files** (APNG, animated GIF/WebP, multi-page TIFF): only the first
|
||||
frame or page is decoded, and a single frame is written. `FormatCaps::animation` marks
|
||||
containers that can hold more frames.
|
||||
containers that can hold more frames. The decoded image then carries a
|
||||
`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).
|
||||
* **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.
|
||||
* **EXIF orientation** is preserved as data but not applied to the pixels.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on
|
||||
decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten
|
||||
to 1 (`DecodeOptions::keep_orientation` opts out). Encoders always write Orientation = 1
|
||||
(`upright_exif`, `upright_xmp`, which change only the tag's value, kept in its own type), so upright pixels
|
||||
are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before
|
||||
its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value
|
||||
reads as 1; with duplicate entries the first wins. `Limits` are checked again on the upright
|
||||
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
|
||||
`Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in
|
||||
release).
|
||||
* **JPEG**
|
||||
* 8-bit only. Neither decoder backend supports 12-bit.
|
||||
* A file cut off inside its image data decodes leniently (a baseline JPEG's missing rows come
|
||||
out grey) but never silently: the image carries `DecodeWarning::Truncated`. A file that ends
|
||||
before any scan data is an error.
|
||||
* CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an
|
||||
APP14 marker.
|
||||
* Text is not written because there is no COM-segment support.
|
||||
@@ -102,7 +129,7 @@ the same.
|
||||
|
||||
## Limits (decompression bombs)
|
||||
|
||||
`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc } }` guards
|
||||
`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc }, .. }` guards
|
||||
decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is
|
||||
also passed to the underlying decoders. A violation returns `CodecError::LimitExceeded`. The
|
||||
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
|
||||
|
||||
@@ -14,7 +14,9 @@ cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
photocraft-codecs = { path = ".." }
|
||||
photocraft-codecs = { path = "..", features = ["heif"] }
|
||||
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
|
||||
heic-rs = "=0.1.1"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
@@ -53,3 +55,10 @@ path = "fuzz_targets/decode_exr.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_heif"
|
||||
path = "fuzz_targets/decode_heif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -8,6 +8,7 @@ use photocraft_codecs::{decode_with, encode, DecodeOptions, EncodeOptions, Limit
|
||||
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()
|
||||
};
|
||||
if let Ok(img) = decode_with(data, &opts)
|
||||
&& let Some(f) = photocraft_codecs::detect(data)
|
||||
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, 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::OpenExr, data, &opts);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
|
||||
// heic-rs is young and parses untrusted HEIF/HEVC: run with `-timeout=10` so a hang is reported,
|
||||
// not just a panic. The codec turns a heic-rs panic into an error, so heic-rs is also called
|
||||
// directly, where its panics still reach the fuzzer. Both orientation paths are covered.
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let keep_orientation = data.first().is_some_and(|b| b & 1 == 1);
|
||||
let raw = heic_rs::DecodeOptions::default().with_max_pixels(Some(1 << 22)).with_transforms(!keep_orientation).with_alpha(true);
|
||||
let _ = heic_rs::decode(data, &raw);
|
||||
let limits = Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 };
|
||||
let _ = decode_as_with(Format::Heif, data, &DecodeOptions { limits, keep_orientation });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, 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::Jpeg, data, &opts);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, 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::Png, data, &opts);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, 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::Tiff, data, &opts);
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Deep OpenEXR (`deepscanline` / `deeptile`): a variable-length list of samples per pixel,
|
||||
//! each with its own colour, alpha and depth. The `exr` crate rejects deep headers, so the
|
||||
//! chunk stream is walked directly from the bytes, per the OpenEXR file-layout specification:
|
||||
//! a deep chunk carries its coordinates, the packed size of the sample-count table, the
|
||||
//! packed and unpacked sizes of the sample data, then the compressed count table (cumulative
|
||||
//! within each row of the block) and the compressed sample data (row by row, each row channel
|
||||
//! by channel). The layout was checked against files written by the OpenEXR library. Deep data permits only NONE, RLE, ZIPS and ZIP
|
||||
//! compression; we support the first three (one scan line per block, which keeps the count
|
||||
//! table unambiguous) and decline ZIP with `unsupported`.
|
||||
//!
|
||||
//! [`decode_deep`] returns the structured samples ([`DeepImage`]); [`flatten`] composites
|
||||
//! them into a flat `Image` — samples sorted by Z, front-to-back `over` with premultiplied
|
||||
//! colour, un-premultiplied into straight alpha (HDR values are kept, not clamped) — because the flat document model has no
|
||||
//! per-pixel depth.
|
||||
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
|
||||
use exr::meta::attribute::{LevelMode, SampleType as ExrSample};
|
||||
use exr::meta::{BlockDescription, MetaData};
|
||||
use exr::prelude::Compression;
|
||||
use half::f16;
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::OpenExr;
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
fn unsup(reason: impl Into<String>) -> CodecError {
|
||||
CodecError::unsupported(F, reason)
|
||||
}
|
||||
|
||||
// --- checked little-endian reads -------------------------------------------------------------
|
||||
// Every length and offset comes from the file, so every read is bounds-checked and a short
|
||||
// read is a malformed file, never a panic.
|
||||
|
||||
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
let mut v = vec![0u8; n];
|
||||
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn i32(cur: &mut Cursor<&[u8]>) -> Result<i32, CodecError> {
|
||||
let b = read_exact(cur, 4, "an i32")?;
|
||||
Ok(i32::from_le_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)]))
|
||||
}
|
||||
|
||||
fn u64(cur: &mut Cursor<&[u8]>) -> Result<u64, CodecError> {
|
||||
let b = read_exact(cur, 8, "a u64")?;
|
||||
let a: [u8; 8] = b.try_into().unwrap_or([0; 8]);
|
||||
Ok(u64::from_le_bytes(a))
|
||||
}
|
||||
|
||||
fn seek_to(cur: &mut Cursor<&[u8]>, pos: u64) -> Result<(), CodecError> {
|
||||
if pos > cur.get_ref().len() as u64 {
|
||||
return Err(err(format!("chunk offset {pos} points past the end of the file")));
|
||||
}
|
||||
cur.seek(SeekFrom::Start(pos)).map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- decompression ----------------------------------------------------------------------------
|
||||
|
||||
/// Undo the ZIP predictor: each byte was stored as its difference to the previous byte plus
|
||||
/// 128 (so a zero difference is 0x80), modulo 256. Applies to the sample-count table and the
|
||||
/// sample data alike.
|
||||
fn differences_to_samples(buf: &mut [u8]) {
|
||||
if let Some(&first) = buf.first() {
|
||||
let mut prev = i16::from(first);
|
||||
for b in buf.iter_mut().skip(1) {
|
||||
let s = (prev + i16::from(*b) - 128) as u8;
|
||||
prev = i16::from(s);
|
||||
*b = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
|
||||
/// holding every second byte of the original; weave them back together.
|
||||
fn interleave_byte_blocks(buf: &mut [u8]) {
|
||||
let half = buf.len().div_ceil(2);
|
||||
let mut out = vec![0u8; buf.len()];
|
||||
let mut k = 0usize;
|
||||
for i in 0..buf.len() / 2 {
|
||||
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
|
||||
if let (Some(a), Some(b), Some(pair)) = (a, b, out.get_mut(k..k + 2)) {
|
||||
if let Some(byte) = pair.first_mut() {
|
||||
*byte = a;
|
||||
}
|
||||
if let Some(byte) = pair.get_mut(1) {
|
||||
*byte = b;
|
||||
}
|
||||
}
|
||||
k += 2;
|
||||
}
|
||||
if buf.len() % 2 == 1 && half > 0 {
|
||||
let last = buf.get(half - 1).copied();
|
||||
if let (Some(last), Some(slot)) = (last, out.get_mut(k)) {
|
||||
*slot = last;
|
||||
}
|
||||
}
|
||||
buf.copy_from_slice(&out);
|
||||
}
|
||||
|
||||
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
|
||||
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
|
||||
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
let count = src[i] as i8;
|
||||
i += 1;
|
||||
let n = if count < 0 { (-(count as i32)) as usize } else { count as usize + 1 };
|
||||
if out.len().saturating_add(n) > expected {
|
||||
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
|
||||
}
|
||||
if count < 0 {
|
||||
let end = i.saturating_add(n);
|
||||
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
|
||||
if run.len() != n {
|
||||
return Err(err(format!("{what}: RLE run points past the compressed data")));
|
||||
}
|
||||
out.extend_from_slice(run);
|
||||
i = end;
|
||||
} else {
|
||||
let b = src.get(i).copied().ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
|
||||
i += 1;
|
||||
out.resize(out.len() + n, b);
|
||||
}
|
||||
}
|
||||
if out.len() != expected {
|
||||
return Err(err(format!("{what}: RLE data expands to {} bytes, expected {expected}", out.len())));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decompress `what` to exactly `expected` bytes. Like flat blocks, a payload that already
|
||||
/// has the uncompressed length is stored raw whatever the declared compression.
|
||||
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
if packed.len() == expected {
|
||||
return Ok(packed.to_vec());
|
||||
}
|
||||
match compression {
|
||||
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
|
||||
Compression::RLE => {
|
||||
// Like ZIPS, RLE runs over the predicted, byte-split stream.
|
||||
let mut out = rle_decode(packed, expected, what)?;
|
||||
differences_to_samples(&mut out);
|
||||
interleave_byte_blocks(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
Compression::ZIP1 => {
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
flate2::read::ZlibDecoder::new(packed)
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
|
||||
if out.len() != expected {
|
||||
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
|
||||
}
|
||||
differences_to_samples(&mut out);
|
||||
interleave_byte_blocks(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- the deep chunk stream ---------------------------------------------------------------------
|
||||
|
||||
/// One parsed chunk: where its pixels go and its cumulative sample counts, plus where to
|
||||
/// find the compressed sample data in the second pass.
|
||||
struct DeepChunk {
|
||||
/// Global pixel indices of the block's pixels, in block order (row-major within the block).
|
||||
pixels: Vec<usize>,
|
||||
/// Pixels per row of the block (the data window width for a scan line, the clipped tile
|
||||
/// width for a tile).
|
||||
row_w: usize,
|
||||
/// Cumulative sample count table of the block: `len == pixels.len() + 1`.
|
||||
prefix: Vec<u32>,
|
||||
/// File offset of the chunk start.
|
||||
pos: u64,
|
||||
/// Size of the compressed sample data in the file.
|
||||
packed_data: u64,
|
||||
/// The decompressed sample-data size the file declares (validated against the table).
|
||||
unpacked_data: u64,
|
||||
}
|
||||
|
||||
fn sample_bytes(t: ExrSample) -> usize {
|
||||
match t {
|
||||
ExrSample::U32 | ExrSample::F32 => 4,
|
||||
ExrSample::F16 => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn our_sample_type(t: ExrSample) -> SampleType {
|
||||
match t {
|
||||
ExrSample::F16 => SampleType::F16,
|
||||
ExrSample::U32 | ExrSample::F32 => SampleType::F32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the deep samples of the first deep part. `meta` must come from an unvalidated
|
||||
/// [`MetaData::read_from_buffered`] of the same bytes (deep headers fail validation).
|
||||
pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
|
||||
let header_index = meta.headers.iter().position(|h| h.deep).ok_or_else(|| unsup("the file has no deep part"))?;
|
||||
let header = meta.headers.get(header_index).ok_or_else(|| unsup("the file has no deep part"))?;
|
||||
let compression = header.compression;
|
||||
if !matches!(compression, Compression::Uncompressed | Compression::RLE | Compression::ZIP1) {
|
||||
return Err(unsup(format!("deep data with {compression:?} compression (NONE, RLE and ZIPS are supported)")));
|
||||
}
|
||||
|
||||
let dw = header.data_window();
|
||||
let (w, h) = (dw.size.0, dw.size.1);
|
||||
if w == 0 || h == 0 {
|
||||
return Err(err("the data window is empty"));
|
||||
}
|
||||
if w > u32::MAX as usize || h > u32::MAX as usize {
|
||||
return Err(err("the data window is too large"));
|
||||
}
|
||||
let y0 = dw.position.1;
|
||||
let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;
|
||||
limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;
|
||||
|
||||
let exr_types: Vec<ExrSample> = header.channels.list.iter().map(|c| c.sample_type).collect();
|
||||
let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum();
|
||||
let multipart = meta.requirements.has_multiple_layers;
|
||||
|
||||
// The offset tables follow the headers: `chunk_count` u64 per header, in header order.
|
||||
// Our part's table starts after the tables of all earlier parts.
|
||||
let mut table_pos = header_end(bytes)?;
|
||||
for h_ in meta.headers.iter().take(header_index) {
|
||||
table_pos = table_pos.saturating_add((h_.chunk_count.min(1 << 24) as u64).saturating_mul(8));
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
seek_to(&mut cur, table_pos)?;
|
||||
let mut offsets = Vec::with_capacity(header.chunk_count.min(1 << 20));
|
||||
for _ in 0..header.chunk_count {
|
||||
offsets.push(u64(&mut cur)?);
|
||||
}
|
||||
let tables_end = cur.position();
|
||||
|
||||
// Block geometry.
|
||||
let (tiles_x, tile_w, tile_h, slots) = match &header.blocks {
|
||||
BlockDescription::ScanLines => (0usize, w, 1usize, h), // one line per block for NONE/RLE/ZIPS
|
||||
BlockDescription::Tiles(t) => {
|
||||
if t.level_mode != LevelMode::Singular {
|
||||
return Err(unsup("deep mip-map or rip-map tiles (single-level tiles are supported)"));
|
||||
}
|
||||
let (tw, th) = (t.tile_size.0.max(1), t.tile_size.1.max(1));
|
||||
let tx = w.saturating_add(tw - 1) / tw;
|
||||
let ty = h.saturating_add(th - 1) / th;
|
||||
(tx, tw, th, tx.checked_mul(ty).ok_or_else(|| err("tile count overflows"))?)
|
||||
}
|
||||
};
|
||||
|
||||
let mut counts = vec![0u32; npixels];
|
||||
let mut seen = vec![false; slots];
|
||||
let mut chunks: Vec<DeepChunk> = Vec::with_capacity(offsets.len());
|
||||
|
||||
for &off in &offsets {
|
||||
if off < tables_end || off >= bytes.len() as u64 {
|
||||
return Err(err(format!("chunk offset {off} is outside the chunk area of the file")));
|
||||
}
|
||||
seek_to(&mut cur, off)?;
|
||||
if multipart {
|
||||
let part = i32(&mut cur)?;
|
||||
if part < 0 || part as usize != header_index {
|
||||
return Err(err(format!("chunk declares part {part}, expected {header_index}")));
|
||||
}
|
||||
}
|
||||
// Block coordinates → the block's global pixel range and its slot.
|
||||
let (block_pixels, slot, row_w): (Vec<usize>, usize, usize) = match &header.blocks {
|
||||
BlockDescription::ScanLines => {
|
||||
let y = i32(&mut cur)?;
|
||||
let row = match y.checked_sub(y0) {
|
||||
Some(d) if d >= 0 && (d as usize) < h => d as usize,
|
||||
_ => return Err(err(format!("scan line block y={y} is outside the data window"))),
|
||||
};
|
||||
((0..w).map(|x| row * w + x).collect(), row, w)
|
||||
}
|
||||
BlockDescription::Tiles(_) => {
|
||||
let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
|
||||
if (lx, ly) != (0, 0) {
|
||||
return Err(err(format!("deep tile at level ({lx},{ly}); only level 0 exists in a single-level file")));
|
||||
}
|
||||
if tx < 0 || ty < 0 || (tx as usize) >= tiles_x || (ty as usize) * tile_h >= h {
|
||||
return Err(err(format!("tile ({tx},{ty}) is outside the data window")));
|
||||
}
|
||||
let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h);
|
||||
let rows = (by + tile_h).min(h);
|
||||
let cols = (bx + tile_w).min(w);
|
||||
let mut px = Vec::with_capacity((rows - by) * (cols - bx));
|
||||
for yy in by..rows {
|
||||
for xx in bx..cols {
|
||||
px.push(yy.saturating_mul(w).saturating_add(xx));
|
||||
}
|
||||
}
|
||||
let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;
|
||||
(px, slot, cols - bx)
|
||||
}
|
||||
};
|
||||
if seen.get(slot).copied().unwrap_or(true) {
|
||||
return Err(err(format!("two chunks cover block {slot}")));
|
||||
}
|
||||
if let Some(s) = seen.get_mut(slot) {
|
||||
*s = true;
|
||||
}
|
||||
|
||||
let packed_table = u64(&mut cur)?;
|
||||
let packed_data = u64(&mut cur)?;
|
||||
let unpacked_data = u64(&mut cur)?;
|
||||
let remaining = (bytes.len() as u64).saturating_sub(cur.position());
|
||||
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
|
||||
return Err(err("deep chunk sizes point past the end of the file"));
|
||||
}
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
|
||||
// An edge tile's table may still be stored at the full tile size (its pixels first, in
|
||||
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
|
||||
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
|
||||
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
|
||||
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
|
||||
|
||||
// The table is cumulative within each row of the block (a tile restarts at every row);
|
||||
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
|
||||
let mut prefix = Vec::with_capacity(block_pixels.len() + 1);
|
||||
prefix.push(0u32);
|
||||
let (mut row_base, mut prev) = (0u32, 0u32);
|
||||
for (i, p) in block_pixels.iter().enumerate() {
|
||||
let b: [u8; 4] = raw.get(i * 4..i * 4 + 4).and_then(|s| s.try_into().ok()).ok_or_else(|| err("the count table is short"))?;
|
||||
let v = u32::from_le_bytes(b);
|
||||
if row_w > 0 && i % row_w == 0 {
|
||||
row_base = row_base.checked_add(prev).ok_or_else(|| err("the block's sample count overflows"))?;
|
||||
prev = 0;
|
||||
}
|
||||
if v < prev {
|
||||
return Err(err("the sample-count table decreases (malformed cumulative counts)"));
|
||||
}
|
||||
if let Some(c) = counts.get_mut(*p) {
|
||||
*c = v - prev;
|
||||
}
|
||||
prev = v;
|
||||
prefix.push(row_base.checked_add(v).ok_or_else(|| err("the block's sample count overflows"))?);
|
||||
}
|
||||
let total = prefix.last().copied().unwrap_or(0) as u64;
|
||||
let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?;
|
||||
if expected_data != unpacked_data {
|
||||
return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}")));
|
||||
}
|
||||
chunks.push(DeepChunk { pixels: block_pixels, row_w, prefix, pos: off, packed_data, unpacked_data });
|
||||
}
|
||||
if seen.iter().any(|s| !s) {
|
||||
let missing = seen.iter().filter(|s| !**s).count();
|
||||
return Err(err(format!("{missing} of {slots} chunks are missing from the offset table")));
|
||||
}
|
||||
|
||||
// Global cumulative counts in raster order.
|
||||
let mut deep_counts = Vec::with_capacity(npixels + 1);
|
||||
deep_counts.push(0u64);
|
||||
let mut acc = 0u64;
|
||||
for &c in &counts {
|
||||
acc = acc.checked_add(c as u64).ok_or_else(|| err("the total sample count overflows"))?;
|
||||
deep_counts.push(acc);
|
||||
}
|
||||
let total_samples = acc;
|
||||
let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?;
|
||||
if sample_bytes_total > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!("deep samples need {sample_bytes_total} bytes, exceeding max_alloc {}", limits.max_alloc)));
|
||||
}
|
||||
|
||||
let mut channels: Vec<DeepChannel> = header
|
||||
.channels
|
||||
.list
|
||||
.iter()
|
||||
.map(|c| DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: vec![0f32; total_samples as usize] })
|
||||
.collect();
|
||||
|
||||
// Second pass: the compressed sample data, scattered per pixel (a block's channel data
|
||||
// is contiguous in the file but the pixels' samples interleave with other blocks').
|
||||
for chunk in &chunks {
|
||||
seek_to(&mut cur, chunk.pos)?;
|
||||
if multipart {
|
||||
let _part = i32(&mut cur)?;
|
||||
}
|
||||
match &header.blocks {
|
||||
BlockDescription::ScanLines => {
|
||||
let _y = i32(&mut cur)?;
|
||||
}
|
||||
BlockDescription::Tiles(_) => {
|
||||
let _coords = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
|
||||
}
|
||||
}
|
||||
let packed_table = u64(&mut cur)?;
|
||||
let _packed_data = u64(&mut cur)?;
|
||||
let _unpacked_data = u64(&mut cur)?;
|
||||
// The count table sits between the sizes and the sample data; skip its bytes.
|
||||
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
|
||||
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
|
||||
// Row by row (a scan line block is one row), and within a row channel by channel, as
|
||||
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
|
||||
let mut in_off = 0usize;
|
||||
let row_w = chunk.row_w.max(1);
|
||||
for (r, row) in chunk.pixels.chunks(row_w).enumerate() {
|
||||
let i0 = r * row_w;
|
||||
let row_from = chunk.prefix.get(i0).copied().unwrap_or(0) as usize;
|
||||
let row_to = chunk.prefix.get(i0 + row.len()).copied().unwrap_or(0) as usize;
|
||||
let row_total = row_to.saturating_sub(row_from);
|
||||
for (ci, t) in exr_types.iter().enumerate() {
|
||||
let sb = sample_bytes(*t);
|
||||
let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?;
|
||||
let end = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;
|
||||
let src = raw.get(in_off..end).ok_or_else(|| err("the sample data is short"))?;
|
||||
in_off = end;
|
||||
let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?;
|
||||
for (j, p) in row.iter().enumerate() {
|
||||
let from = (chunk.prefix.get(i0 + j).copied().unwrap_or(0) as usize).saturating_sub(row_from);
|
||||
let n = (chunk.prefix.get(i0 + j + 1).copied().unwrap_or(0) as usize).saturating_sub(row_from).saturating_sub(from);
|
||||
let to = deep_counts.get(*p).copied().unwrap_or(0) as usize;
|
||||
for k in 0..n {
|
||||
let base = (from + k) * sb;
|
||||
let v = match t {
|
||||
ExrSample::F16 => src.get(base..base + 2).map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32()),
|
||||
ExrSample::F32 => src.get(base..base + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
ExrSample::U32 => src.get(base..base + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32),
|
||||
};
|
||||
let (Some(v), Some(slot)) = (v, out.samples.get_mut(to + k)) else {
|
||||
return Err(err("the sample data is short"));
|
||||
};
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
|
||||
}
|
||||
|
||||
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
|
||||
/// consumes exactly the version field and the headers, so re-reading with a fresh cursor
|
||||
/// and taking its end position yields it.
|
||||
fn header_end(bytes: &[u8]) -> Result<u64, CodecError> {
|
||||
let mut cur = Cursor::new(bytes);
|
||||
MetaData::read_from_buffered(&mut cur, false).map_err(err)?;
|
||||
Ok(cur.position())
|
||||
}
|
||||
|
||||
/// Composite the deep samples into a flat image: per pixel, samples sorted by Z (stable; the
|
||||
/// file is assumed messy), front-to-back `over` with premultiplied colour, un-premultiplied
|
||||
/// into straight alpha at the end. Without any alpha channel the front-most sample wins,
|
||||
/// opaque. Z, ZBack and other deep channels influence the flat result only through the
|
||||
/// ordering; volume samples (with ZBack) are approximated as point samples.
|
||||
pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
||||
let npx = deep.width as usize * deep.height as usize;
|
||||
let has = |n: &str| deep.channel(n).is_some();
|
||||
let rgb = has("R") && has("G") && has("B");
|
||||
let gray = !rgb && (has("Y") || deep.channels.len() == 1);
|
||||
let layout = if rgb || gray {
|
||||
if has("A") || has("AR") || has("AG") || has("AB") {
|
||||
if rgb { ChannelLayout::Rgba } else { ChannelLayout::GrayA }
|
||||
} else if rgb {
|
||||
ChannelLayout::Rgb
|
||||
} else {
|
||||
ChannelLayout::Gray
|
||||
}
|
||||
} else {
|
||||
return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part"));
|
||||
};
|
||||
let nc = layout.channels();
|
||||
let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) };
|
||||
let (r, g, b) = (samples("R"), samples("G"), samples("B"));
|
||||
let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB"));
|
||||
let (a, z) = (samples("A"), samples("Z"));
|
||||
let y = samples("Y");
|
||||
let lum = if y.is_empty() { deep.channels.first().map(|c| c.samples.as_slice()).unwrap_or(&[]) } else { y };
|
||||
|
||||
let mut out = vec![0f32; npx * nc];
|
||||
let mut order: Vec<u32> = Vec::new();
|
||||
let mut max_samples = 0u32;
|
||||
for p in 0..npx {
|
||||
let range = deep.sample_range(p);
|
||||
let (s0, n) = (range.start as usize, (range.end - range.start) as usize);
|
||||
max_samples = max_samples.max(u32::try_from(n).unwrap_or(u32::MAX));
|
||||
if n == 0 {
|
||||
continue; // no samples: transparent black
|
||||
}
|
||||
order.clear();
|
||||
order.extend(0u32..n as u32);
|
||||
if !z.is_empty() {
|
||||
// Stable sort by Z; a NaN depth (invalid) sorts last, deterministically.
|
||||
order.sort_by(|&i, &j| {
|
||||
let zi = z.get(s0 + i as usize).copied().unwrap_or(f32::NAN);
|
||||
let zj = z.get(s0 + j as usize).copied().unwrap_or(f32::NAN);
|
||||
zi.total_cmp(&zj)
|
||||
});
|
||||
}
|
||||
let at = |ch: &[f32], i: u32| ch.get(s0 + i as usize).copied().unwrap_or(0.0);
|
||||
// Per-colour-channel alpha: AR/AG/AB when present, else A, else opaque.
|
||||
let alpha = |i: u32| -> [f32; 3] {
|
||||
let s = at(a, i);
|
||||
[
|
||||
if !a.is_empty() && ar.is_empty() {
|
||||
s
|
||||
} else if !ar.is_empty() {
|
||||
at(ar, i)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
if !a.is_empty() && ag.is_empty() {
|
||||
s
|
||||
} else if !ag.is_empty() {
|
||||
at(ag, i)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
if !a.is_empty() && ab.is_empty() {
|
||||
s
|
||||
} else if !ab.is_empty() {
|
||||
at(ab, i)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
]
|
||||
};
|
||||
let mut cacc = [0f32; 3]; // premultiplied colour
|
||||
let mut aacc = [0f32; 3]; // accumulated alpha
|
||||
for &i in &order {
|
||||
let ai = alpha(i);
|
||||
let src = if rgb { [at(r, i), at(g, i), at(b, i)] } else { [at(lum, i); 3] };
|
||||
let mut covered = true;
|
||||
for k in 0..3 {
|
||||
let t = (1.0 - aacc[k]).max(0.0);
|
||||
cacc[k] += src[k] * t;
|
||||
aacc[k] += t * ai[k];
|
||||
covered &= aacc[k] >= 1.0;
|
||||
}
|
||||
if covered {
|
||||
break; // everything behind is hidden
|
||||
}
|
||||
}
|
||||
let a_out = if a.is_empty() && ar.is_empty() && ag.is_empty() && ab.is_empty() { 1.0 } else { (aacc[0] + aacc[1] + aacc[2]) / 3.0 };
|
||||
let a_clamped = a_out.clamp(0.0, 1.0);
|
||||
// Un-premultiply into straight alpha (the deep colours are premultiplied).
|
||||
let straight = |c: f32| if a_clamped > 1e-6 { c / a_clamped } else { 0.0 };
|
||||
let base = p * nc;
|
||||
for (k, v) in [straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), a_clamped].into_iter().enumerate() {
|
||||
if let Some(slot) = out.get_mut(base + k) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
if !rgb {
|
||||
// Luminance: keep only channel 0, and the alpha behind it.
|
||||
if let Some(slot) = out.get_mut(base) {
|
||||
*slot = straight(cacc[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
|
||||
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
|
||||
Ok(img)
|
||||
}
|
||||
@@ -21,6 +21,15 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
/// An `exr`-crate error, with its `NotSupported` kind kept distinct from malformed data
|
||||
/// (the same split the TIFF codec makes), so unsupported features say so.
|
||||
fn map_exr(e: exr::error::Error) -> CodecError {
|
||||
match e {
|
||||
exr::error::Error::NotSupported(message) => CodecError::unsupported(F, format!("not supported: {message}")),
|
||||
e => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip an optional `layer.` prefix from a channel name.
|
||||
fn base_name(name: &str) -> &str {
|
||||
name.rsplit('.').next().unwrap_or(name)
|
||||
@@ -36,8 +45,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
limits.check_bytes(w, h, bpp)?;
|
||||
}
|
||||
|
||||
let image =
|
||||
read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?;
|
||||
// Deep parts are rejected by the `exr` crate's reader; walk their chunks ourselves and
|
||||
// composite the samples into the flat image (a warning says what was lost).
|
||||
if meta.requirements.has_deep_data {
|
||||
let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?;
|
||||
return super::deep_exr::flatten(&deep);
|
||||
}
|
||||
|
||||
let image = read()
|
||||
.no_deep_data()
|
||||
.largest_resolution_level()
|
||||
.all_channels()
|
||||
.first_valid_layer()
|
||||
.all_attributes()
|
||||
.from_buffered(Cursor::new(bytes))
|
||||
.map_err(map_exr)?;
|
||||
let layer = &image.layer_data;
|
||||
let (w, h) = (layer.size.0, layer.size.1);
|
||||
let channels = &layer.channel_data.list;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! HEIF / HEIC (the iPhone and Mac photo format), read-only: see [`crate::ASYMMETRIC_EXCEPTIONS`].
|
||||
//!
|
||||
//! Decoding lives in the optional `photocraft-heif` crate (heic-rs, a pure-Rust HEVC
|
||||
//! still-picture decoder), enabled by this crate's `heif` feature. Without it, HEIF files are
|
||||
//! still detected and opening one is an [`CodecError::Unsupported`] error, never a panic.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
|
||||
//! so those are applied by the decoder and the EXIF Orientation is rewritten to 1. Its EXIF tag
|
||||
//! only mirrors what the container already says, and applying it as well would turn the photo
|
||||
//! twice.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::Image;
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of PhotoCraft";
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn decode(_bytes: &[u8], _limits: &Limits, _keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
Err(CodecError::unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
fn err(e: photocraft_heif::Error) -> CodecError {
|
||||
match e {
|
||||
photocraft_heif::Error::Unsupported(what) => CodecError::unsupported(F, what),
|
||||
photocraft_heif::Error::Limit(what) => CodecError::LimitExceeded(what),
|
||||
photocraft_heif::Error::Malformed(what) => CodecError::malformed(F, what),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
|
||||
use crate::image::{ChannelLayout, Metadata, SampleType};
|
||||
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
let info = photocraft_heif::probe(bytes).map_err(err)?;
|
||||
let layout = if info.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) };
|
||||
limits.check(w, h, layout, sample)?;
|
||||
let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation };
|
||||
let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?;
|
||||
// The decoder re-reads the container; trust its output's shape, not the probe's.
|
||||
let layout = if decoded.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
|
||||
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
|
||||
img.icc = decoded.icc;
|
||||
img.meta = Metadata {
|
||||
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
|
||||
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
fn without_the_feature_heif_is_a_clear_unsupported_error() {
|
||||
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
assert_eq!(crate::detect(header), Some(F), "still recognised");
|
||||
let r = crate::decode(header);
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("isn't included in this build")), "{r:?}");
|
||||
assert!(!crate::caps(F).read);
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn with_the_feature_heif_is_readable_and_errors_map() {
|
||||
assert!(crate::caps(F).read);
|
||||
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
|
||||
assert!(matches!(crate::decode_as(F, b""), Err(CodecError::Malformed { .. } | CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
/// Found by the `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice out of range
|
||||
/// (`boxes.rs:130`, "slice index starts at 24 but ends at 16"). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50, 0x72, 0x6d,
|
||||
0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_file_error() {
|
||||
assert_eq!(crate::detect(&HEIC_RS_BOX_PANIC), Some(Format::Heif));
|
||||
for opts in [crate::DecodeOptions::default(), crate::DecodeOptions { keep_orientation: true, ..Default::default() }] {
|
||||
let r = crate::decode_with(&HEIC_RS_BOX_PANIC, &opts);
|
||||
assert!(matches!(r, Err(CodecError::Malformed { format: Format::Heif, .. })), "{r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ use zune_core::options::DecoderOptions;
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
use crate::orientation::{upright_exif, upright_xmp};
|
||||
|
||||
const F: Format = Format::Jpeg;
|
||||
const EXIF_HEADER: &[u8] = b"Exif\0\0";
|
||||
@@ -100,11 +101,70 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
||||
m
|
||||
}
|
||||
|
||||
/// How a JPEG's data ends, seen from its marker structure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DataEnd {
|
||||
/// An end-of-image marker follows the scans (or the structure is too odd to tell).
|
||||
Complete,
|
||||
/// The file ends inside the image data, before the end-of-image marker.
|
||||
Truncated,
|
||||
/// The file ends before the first scan holds any data: nothing can be decoded.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Walks the marker segments (skipped by their length) and the entropy-coded scan data
|
||||
/// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte),
|
||||
/// so an embedded thumbnail's end marker or data after the end of the image don't count.
|
||||
fn data_end(b: &[u8]) -> DataEnd {
|
||||
// Where the bytes run out: inside the image data once a scan has held some.
|
||||
let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty };
|
||||
let mut i = 2; // past SOI
|
||||
let mut scan_data = false;
|
||||
loop {
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data) };
|
||||
if byte != 0xFF {
|
||||
// Not where a marker should be: too odd to judge.
|
||||
return DataEnd::Complete;
|
||||
}
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data) };
|
||||
match code {
|
||||
0xD9 => return DataEnd::Complete,
|
||||
0xFF => i += 1,
|
||||
0x01 | 0xD0..=0xD8 => i += 2,
|
||||
_ => {
|
||||
let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else {
|
||||
return cut(scan_data);
|
||||
};
|
||||
i = i.saturating_add(2 + usize::from(len));
|
||||
if code == 0xDA {
|
||||
let start = i;
|
||||
loop {
|
||||
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else {
|
||||
return cut(scan_data || b.len() > start);
|
||||
};
|
||||
i += p;
|
||||
match b.get(i + 1) {
|
||||
Some(0x00 | 0xD0..=0xD7) => i += 2,
|
||||
Some(0xFF) => i += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
scan_data |= i > start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let end = data_end(bytes);
|
||||
if end == DataEnd::Empty {
|
||||
return Err(err("the file ends before any image data"));
|
||||
}
|
||||
let meta = scan_metadata(bytes);
|
||||
if let Some((w, h, nc)) = meta.frame {
|
||||
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
|
||||
@@ -148,7 +208,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -185,14 +248,12 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
{
|
||||
enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 });
|
||||
}
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
let mut seg = EXIF_HEADER.to_vec();
|
||||
seg.extend_from_slice(exif);
|
||||
// Metadata too large for its one segment is left out (and reported by the fidelity
|
||||
// warnings) rather than failing the whole export.
|
||||
if let Some(seg) = img.meta.exif.as_deref().and_then(exif_segment) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
let mut seg = XMP_HEADER.to_vec();
|
||||
seg.extend_from_slice(xmp.as_bytes());
|
||||
if let Some(seg) = img.meta.xmp.as_deref().and_then(xmp_segment) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
}
|
||||
@@ -204,3 +265,63 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
|
||||
const MAX_SEGMENT: usize = 65533;
|
||||
|
||||
/// XMP properties (local names, any namespace prefix) that only describe the layered document:
|
||||
/// every document ever placed in it, the text of every type layer. A flat image doesn't need them,
|
||||
/// and they alone can outgrow a segment.
|
||||
const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
|
||||
|
||||
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
|
||||
pub(crate) fn exif_segment(exif: &[u8]) -> Option<Vec<u8>> {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
let seg = [EXIF_HEADER, upright_exif(exif).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
|
||||
/// properties are left out; `None` when it still doesn't fit.
|
||||
pub(crate) fn xmp_segment(xmp: &str) -> Option<Vec<u8>> {
|
||||
let xmp = upright_xmp(xmp);
|
||||
let seg = |x: &str| [XMP_HEADER, x.as_bytes()].concat();
|
||||
if XMP_HEADER.len() + xmp.len() <= MAX_SEGMENT {
|
||||
return Some(seg(&xmp));
|
||||
}
|
||||
let trimmed = LAYERED_ONLY_XMP.iter().fold(xmp.into_owned(), |x, name| remove_element(&x, name));
|
||||
(XMP_HEADER.len() + trimmed.len() <= MAX_SEGMENT).then(|| seg(&trimmed))
|
||||
}
|
||||
|
||||
/// `xmp` without its `<prefix:name …>…</prefix:name>` and `<prefix:name …/>` elements. Unbalanced
|
||||
/// markup is kept as is.
|
||||
fn remove_element(xmp: &str, name: &str) -> String {
|
||||
let local = format!(":{name}");
|
||||
let mut out = String::with_capacity(xmp.len());
|
||||
let mut rest = xmp;
|
||||
while let Some(j) = rest.find(&local) {
|
||||
let head = rest.get(..j).unwrap_or_default();
|
||||
let prefix_len = head.bytes().rev().take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')).count();
|
||||
let lt = j - prefix_len;
|
||||
let after = rest.get(j + local.len()..).unwrap_or_default();
|
||||
// An opening tag of exactly this name (not a closing tag, an attribute or a longer name).
|
||||
let opening = prefix_len > 0 && lt > 0 && rest.get(lt - 1..lt) == Some("<");
|
||||
if !opening || !after.starts_with(['>', '/', ' ', '\t', '\r', '\n']) {
|
||||
out.push_str(rest.get(..j + local.len()).unwrap_or_default());
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let Some(gt) = after.find('>') else { break };
|
||||
let end = if after.get(..gt).is_some_and(|s| s.ends_with('/')) {
|
||||
gt + 1
|
||||
} else {
|
||||
let close = format!("</{}{local}>", rest.get(lt..j).unwrap_or_default());
|
||||
let Some(c) = after.find(&close) else { break };
|
||||
c + close.len()
|
||||
};
|
||||
out.push_str(rest.get(..lt - 1).unwrap_or_default());
|
||||
rest = after.get(end..).unwrap_or_default();
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::io::{Cursor, Write};
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
@@ -27,6 +27,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
limits.check_bytes(w, h, 8)?;
|
||||
}
|
||||
let mut reader = decoder.read_info().map_err(map_png_err)?;
|
||||
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
|
||||
let images = reader.info().animation_control.map_or(1, |a| u64::from(a.num_frames) + u64::from(reader.info().frame_control.is_none()));
|
||||
let (color, depth) = reader.output_color_type();
|
||||
let layout = match color {
|
||||
png::ColorType::Grayscale => ChannelLayout::Gray,
|
||||
@@ -82,6 +84,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
img.meta = meta;
|
||||
if images > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(images).ok() });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -131,7 +136,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
info.exif_metadata = Some(exif.clone().into());
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
info.exif_metadata = Some(crate::orientation::upright_exif(exif).into_owned().into());
|
||||
}
|
||||
if let Some((x, y)) = img.meta.dpi
|
||||
&& x > 0.0
|
||||
@@ -164,7 +170,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
res.map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()).map_err(|e| CodecError::encode(F, e))?;
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::orientation::upright_xmp(xmp).into_owned()).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
}
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
//! TIFF via the `tiff` crate: 8/16-bit integer and 32-bit float, gray/RGB/
|
||||
//! CMYK with optional (unassociated) alpha, ICC (34675), XMP (700), DPI and
|
||||
//! a few ASCII text tags. Only the first IFD is read.
|
||||
//! a few ASCII text tags. Only the first IFD is read; further pages are counted and reported
|
||||
//! as a [`DecodeWarning::MorePages`].
|
||||
//!
|
||||
//! Photoshop's private tags are carried as opaque bytes in [`Metadata`]: 34377 (image
|
||||
//! resources) and 37724 (`ImageSourceData`, the layers of a layered TIFF). They are read with
|
||||
//! a bounds-checked walk of the first directory ([`photoshop_tags`]) rather than through the
|
||||
//! decoder, which would materialize a multi-megabyte layer block as one `Value` per byte, and
|
||||
//! written back as BYTE / UNDEFINED tags. The encoder writes files in the native byte order
|
||||
//! (see [`writes_little_endian`]), which the layer data must match.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
@@ -13,11 +21,92 @@ use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, T
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, TiffCompression};
|
||||
|
||||
const F: Format = Format::Tiff;
|
||||
const TAG_XMP: u16 = 700;
|
||||
/// Photoshop image resources (`8BIM` blocks).
|
||||
const TAG_PHOTOSHOP: u16 = 34377;
|
||||
/// Photoshop `ImageSourceData`: the layers of a layered TIFF.
|
||||
const TAG_IMAGE_SOURCE_DATA: u16 = 37724;
|
||||
|
||||
/// `true` when [`encode`] writes little-endian (`II`) files: the `tiff` encoder writes the
|
||||
/// running target's byte order, and every target PhotoCraft builds for is little-endian.
|
||||
/// Byte-order-sensitive payloads (Photoshop layer data) must be produced to match.
|
||||
pub fn writes_little_endian() -> bool {
|
||||
cfg!(target_endian = "little")
|
||||
}
|
||||
|
||||
/// `(34377 image resources, 37724 image source data)` payloads, each absent when the tag is.
|
||||
pub type PhotoshopTags<'a> = (Option<&'a [u8]>, Option<&'a [u8]>);
|
||||
|
||||
/// The raw payloads of the Photoshop private tags in the first directory: `(34377 image
|
||||
/// resources, 37724 image source data)`. Classic and BigTIFF, either byte order; only
|
||||
/// header bytes and the two payloads are touched, and nothing is allocated. Anything malformed
|
||||
/// reads as "absent".
|
||||
pub fn photoshop_tags(b: &[u8]) -> PhotoshopTags<'_> {
|
||||
fn walk(b: &[u8]) -> Option<PhotoshopTags<'_>> {
|
||||
let le = match b.get(0..2)? {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16_at = |o: u64| -> Option<u16> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s = b.get(o..o.checked_add(2)?)?;
|
||||
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
};
|
||||
let u32_at = |o: u64| -> Option<u32> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s: [u8; 4] = b.get(o..o.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
|
||||
};
|
||||
let u64_at = |o: u64| -> Option<u64> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s: [u8; 8] = b.get(o..o.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(if le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
|
||||
};
|
||||
let (big, ifd) = match u16_at(2)? {
|
||||
42 => (false, u64::from(u32_at(4)?)),
|
||||
43 => (true, u64_at(8)?),
|
||||
_ => return None,
|
||||
};
|
||||
let n = if big { u64_at(ifd)? } else { u64::from(u16_at(ifd)?) }.min(4096);
|
||||
let (entry, first, inline) = if big { (20u64, ifd.checked_add(8)?, 8u64) } else { (12, ifd.checked_add(2)?, 4) };
|
||||
let mut resources = None;
|
||||
let mut layers = None;
|
||||
for i in 0..n {
|
||||
let e = first.checked_add(i.checked_mul(entry)?)?;
|
||||
let tag = u16_at(e)?;
|
||||
if tag != TAG_PHOTOSHOP && tag != TAG_IMAGE_SOURCE_DATA {
|
||||
continue;
|
||||
}
|
||||
// BYTE, ASCII, SBYTE or UNDEFINED: one byte per element.
|
||||
if !matches!(u16_at(e + 2)?, 1 | 2 | 6 | 7) {
|
||||
continue;
|
||||
}
|
||||
let count = if big { u64_at(e + 4)? } else { u64::from(u32_at(e + 4)?) };
|
||||
let value_at = e + if big { 12 } else { 8 };
|
||||
let start = if count <= inline {
|
||||
value_at
|
||||
} else if big {
|
||||
u64_at(value_at)?
|
||||
} else {
|
||||
u64::from(u32_at(value_at)?)
|
||||
};
|
||||
let start = usize::try_from(start).ok()?;
|
||||
let data = b.get(start..start.checked_add(usize::try_from(count).ok()?)?)?;
|
||||
if tag == TAG_PHOTOSHOP {
|
||||
resources = Some(data);
|
||||
} else {
|
||||
layers = Some(data);
|
||||
}
|
||||
}
|
||||
Some((resources, layers))
|
||||
}
|
||||
walk(b).unwrap_or((None, None))
|
||||
}
|
||||
|
||||
/// ASCII tags mapped to `Metadata::text` keys.
|
||||
const TEXT_TAGS: &[(Tag, &str)] = &[
|
||||
@@ -159,8 +248,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
};
|
||||
let bytes_per_sample = u64::from(bits.max(8) / 8);
|
||||
limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?;
|
||||
let white_is_zero = dec.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() == Some(PhotometricInterpretation::WhiteIsZero.to_u16());
|
||||
|
||||
let result = dec.read_image().map_err(map_err)?;
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let mut img = match result {
|
||||
@@ -184,19 +271,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
if img.sample_count() != n {
|
||||
return Err(err("sample count mismatch"));
|
||||
}
|
||||
if white_is_zero && layout.is_gray() {
|
||||
let v: Vec<f32> = img
|
||||
.to_normalized()
|
||||
.chunks(layout.channels())
|
||||
.flat_map(|p| {
|
||||
let mut p = p.to_vec();
|
||||
p[0] = 1.0 - p[0];
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?;
|
||||
}
|
||||
|
||||
img.icc = dec.find_tag(Tag::IccProfile).ok().flatten().and_then(value_bytes);
|
||||
let mut meta = Metadata {
|
||||
xmp: dec
|
||||
@@ -229,10 +303,40 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
}
|
||||
let (resources, layers) = photoshop_tags(bytes);
|
||||
meta.photoshop_resources = resources.map(<[u8]>::to_vec);
|
||||
meta.photoshop_layers = layers.map(<[u8]>::to_vec);
|
||||
img.meta = meta;
|
||||
img.warnings.extend(more_pages(&mut dec));
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Counts the pages after the first IFD without decoding their pixels, skipping
|
||||
/// reduced-resolution copies (thumbnails) and transparency masks. The total is unknown when
|
||||
/// a directory can't be read or there are more than `MAX_IFDS`.
|
||||
fn more_pages(dec: &mut Decoder<Cursor<&[u8]>>) -> Option<DecodeWarning> {
|
||||
const MAX_IFDS: u32 = 10_000;
|
||||
let mut pages = 1u32;
|
||||
let mut walked = 0;
|
||||
let mut total = None;
|
||||
loop {
|
||||
if !dec.more_images() {
|
||||
total = Some(pages);
|
||||
break;
|
||||
}
|
||||
walked += 1;
|
||||
if walked > MAX_IFDS || dec.next_image().is_err() {
|
||||
break;
|
||||
}
|
||||
// NewSubfileType bit 0: a reduced-resolution image; bit 2: a transparency mask.
|
||||
let kind = dec.find_tag_unsigned::<u32>(Tag::NewSubfileType).ok().flatten().unwrap_or(0);
|
||||
if kind & 0b101 == 0 {
|
||||
pages += 1;
|
||||
}
|
||||
}
|
||||
(pages > 1).then_some(DecodeWarning::MorePages { total })
|
||||
}
|
||||
|
||||
fn rational_f64(v: tiff::decoder::ifd::Value) -> Option<f64> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
match v {
|
||||
@@ -329,6 +433,8 @@ struct TagSet<'a> {
|
||||
dpi: Option<(f32, f32)>,
|
||||
text: Vec<(Tag, &'a str)>,
|
||||
alpha: bool,
|
||||
photoshop_resources: Option<&'a [u8]>,
|
||||
photoshop_layers: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
fn write_one<C>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()>
|
||||
@@ -356,6 +462,12 @@ where
|
||||
for (tag, s) in &tags.text {
|
||||
d.write_tag(*tag, *s)?;
|
||||
}
|
||||
if let Some(r) = tags.photoshop_resources {
|
||||
d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?;
|
||||
}
|
||||
if let Some(l) = tags.photoshop_layers {
|
||||
d.write_tag(Tag::Unknown(TAG_IMAGE_SOURCE_DATA), Undefined(l))?;
|
||||
}
|
||||
im.write_data(data)
|
||||
}
|
||||
|
||||
@@ -383,12 +495,17 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
|
||||
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(crate::orientation::upright_xmp) } else { None };
|
||||
let tags = TagSet {
|
||||
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
|
||||
xmp: if opts.embed_metadata { img.meta.xmp.as_deref().map(str::as_bytes) } else { None },
|
||||
xmp: xmp.as_deref().map(str::as_bytes),
|
||||
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
|
||||
text,
|
||||
alpha: img.layout().has_alpha(),
|
||||
// Document content, not metadata: written whenever present.
|
||||
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
|
||||
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
|
||||
};
|
||||
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
|
||||
@@ -8,7 +8,7 @@ use image::{DynamicImage, ImageDecoder, ImageFormat};
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
fn image_format(f: Format) -> Option<ImageFormat> {
|
||||
@@ -49,9 +49,49 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
|
||||
let mut img = from_dynamic(f, dynimg)?;
|
||||
img.icc = icc;
|
||||
img.meta.exif = exif;
|
||||
if f == Format::Gif {
|
||||
img.warnings.extend(gif_more_frames(bytes));
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
|
||||
/// The total is unknown when the blocks end before the trailer or break off.
|
||||
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
|
||||
// Size of the colour table a packed-fields byte announces.
|
||||
let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 };
|
||||
// Skips the data sub-blocks starting at `i` (each length-prefixed, ended by a zero length).
|
||||
let skip = |mut i: usize| -> Option<usize> {
|
||||
loop {
|
||||
let n = usize::from(*b.get(i)?);
|
||||
i = i.checked_add(1 + n)?;
|
||||
if n == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Header (6 bytes), logical screen descriptor (7) and the global colour table.
|
||||
let mut i = 13 + table(*b.get(10)?);
|
||||
let mut frames = 0u32;
|
||||
let total = loop {
|
||||
let next = match b.get(i) {
|
||||
Some(0x3B) => break Some(frames),
|
||||
Some(0x21) => skip(i + 2),
|
||||
Some(0x2C) => {
|
||||
frames = frames.saturating_add(1);
|
||||
// Descriptor (10 bytes, packed fields last), local colour table, LZW code size.
|
||||
b.get(i + 9).and_then(|&flags| skip(i + 11 + table(flags)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(n) => i = n,
|
||||
None => break None,
|
||||
}
|
||||
};
|
||||
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
|
||||
}
|
||||
|
||||
pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result<Image, CodecError> {
|
||||
let (w, h) = (d.width(), d.height());
|
||||
Ok(match d {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::io::Cursor;
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
const F: Format = Format::WebP;
|
||||
@@ -30,6 +30,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let mut img = Image::from_u8(w, h, layout, buf)?;
|
||||
img.icc = icc;
|
||||
img.meta = Metadata { exif, xmp, ..Default::default() };
|
||||
if dec.is_animated() && dec.num_frames() > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreFrames { total: Some(dec.num_frames()) });
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -54,10 +57,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
enc.set_exif_metadata(exif.clone());
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
enc.set_exif_metadata(crate::orientation::upright_exif(exif).into_owned());
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
enc.set_xmp_metadata(xmp.as_bytes().to_vec());
|
||||
enc.set_xmp_metadata(crate::orientation::upright_xmp(xmp).as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
enc.encode(img.data(), img.width(), img.height(), ct).map_err(|e| CodecError::encode(F, e))?;
|
||||
|
||||
@@ -41,6 +41,12 @@ pub enum FidelityWarning {
|
||||
XmpDropped,
|
||||
DpiDropped,
|
||||
TextDropped,
|
||||
/// Metadata larger than the format can hold in one piece (`what`: "EXIF", "XMP") will be
|
||||
/// dropped.
|
||||
MetadataTooLarge {
|
||||
what: &'static str,
|
||||
bytes: usize,
|
||||
},
|
||||
/// Image exceeds the format's maximum size; encoding will fail.
|
||||
DimensionsExceeded {
|
||||
max_width: u32,
|
||||
@@ -86,6 +92,7 @@ impl fmt::Display for FidelityWarning {
|
||||
XmpDropped => write!(f, "XMP not supported; it will be dropped"),
|
||||
DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"),
|
||||
TextDropped => write!(f, "text metadata not supported; it will be dropped"),
|
||||
MetadataTooLarge { what, bytes } => write!(f, "{what} ({bytes} bytes) is too large for the format; it will be dropped"),
|
||||
DimensionsExceeded { max_width, max_height } => {
|
||||
write!(f, "image exceeds the format maximum of {max_width}x{max_height}")
|
||||
}
|
||||
@@ -219,6 +226,14 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if m.xmp.is_some() && !c.xmp {
|
||||
w.push(W::XmpDropped);
|
||||
}
|
||||
if format == Format::Jpeg {
|
||||
if let Some(exif) = m.exif.as_deref().filter(|e| crate::codecs::jpeg::exif_segment(e).is_none()) {
|
||||
w.push(W::MetadataTooLarge { what: "EXIF", bytes: exif.len() });
|
||||
}
|
||||
if let Some(xmp) = m.xmp.as_deref().filter(|x| crate::codecs::jpeg::xmp_segment(x).is_none()) {
|
||||
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
|
||||
}
|
||||
}
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
|
||||
+45
-11
@@ -21,6 +21,8 @@ pub enum Format {
|
||||
Hdr,
|
||||
/// Encode-only behind the `avif` feature; see [`ASYMMETRIC_EXCEPTIONS`].
|
||||
Avif,
|
||||
/// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
|
||||
Heif,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
@@ -51,11 +53,20 @@ pub struct FormatCaps {
|
||||
|
||||
/// Formats that are enabled but intentionally not symmetric, with the reason.
|
||||
/// The test-suite asserts that every other enabled format is read+write.
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[(
|
||||
Format::Avif,
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
)];
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
(
|
||||
Format::Avif,
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
),
|
||||
(
|
||||
Format::Heif,
|
||||
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
|
||||
non-default `heif` feature, which official builds enable), so iPhone and Mac photos open; \
|
||||
writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \
|
||||
unsupported. Without the feature HEIF is detected but neither read nor written.",
|
||||
),
|
||||
];
|
||||
|
||||
use ChannelLayout as L;
|
||||
use SampleType as S;
|
||||
@@ -65,7 +76,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
|
||||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 13] = [
|
||||
pub const ALL: [Format; 14] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -79,6 +90,7 @@ impl Format {
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
Format::Avif,
|
||||
Format::Heif,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -106,6 +118,7 @@ impl Format {
|
||||
Format::OpenExr => "OpenEXR",
|
||||
Format::Hdr => "Radiance HDR",
|
||||
Format::Avif => "AVIF",
|
||||
Format::Heif => "HEIF",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +139,7 @@ impl Format {
|
||||
Format::OpenExr => &["exr"],
|
||||
Format::Hdr => &["hdr"],
|
||||
Format::Avif => &["avif"],
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +158,7 @@ impl Format {
|
||||
Format::OpenExr => "image/x-exr",
|
||||
Format::Hdr => "image/vnd.radiance",
|
||||
Format::Avif => "image/avif",
|
||||
Format::Heif => "image/heif",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +205,9 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
|
||||
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
|
||||
Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
|
||||
Format::Heif => {
|
||||
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +239,13 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") {
|
||||
return Some(Format::Hdr);
|
||||
}
|
||||
if b.len() >= 12 && &b[4..8] == b"ftyp" && is_avif_ftyp(b) {
|
||||
return Some(Format::Avif);
|
||||
if b.len() >= 12 && &b[4..8] == b"ftyp" {
|
||||
if is_avif_ftyp(b) {
|
||||
return Some(Format::Avif);
|
||||
}
|
||||
if is_heif_ftyp(b) {
|
||||
return Some(Format::Heif);
|
||||
}
|
||||
}
|
||||
if b.len() >= 14 && b.starts_with(b"BM") {
|
||||
return Some(Format::Bmp);
|
||||
@@ -239,11 +262,22 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_avif_ftyp(b: &[u8]) -> bool {
|
||||
/// The `ftyp` brands: the major brand, then the compatible ones (the minor version between them is skipped).
|
||||
fn ftyp_brands(b: &[u8]) -> impl Iterator<Item = &[u8; 4]> {
|
||||
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||
let end = size.clamp(12, b.len().min(64));
|
||||
let brands = &b[8..end];
|
||||
brands.as_chunks::<4>().0.iter().enumerate().any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis"))
|
||||
b[8..end].as_chunks::<4>().0.iter().enumerate().filter(|(i, _)| *i != 1).map(|(_, c)| c)
|
||||
}
|
||||
|
||||
fn is_avif_ftyp(b: &[u8]) -> bool {
|
||||
ftyp_brands(b).any(|c| c == b"avif" || c == b"avis")
|
||||
}
|
||||
|
||||
/// HEVC-coded HEIF (`heic`, `heix`, ...) or a generic HEIF file (`mif1`, `msf1`, `miaf`) that is
|
||||
/// not AVIF (checked first). Sequence-only files (`hevc`, `msf1`) are recognised so that opening
|
||||
/// one says what is unsupported instead of "unrecognized format".
|
||||
fn is_heif_ftyp(b: &[u8]) -> bool {
|
||||
ftyp_brands(b).any(|c| matches!(c, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"miaf"))
|
||||
}
|
||||
|
||||
fn looks_like_tga(b: &[u8]) -> bool {
|
||||
|
||||
+102
-3
@@ -4,7 +4,10 @@
|
||||
//! in **native endianness**. Integer samples are unsigned and normalized to
|
||||
//! `[0, max]`; float samples are nominally `[0, 1]` but may exceed it (HDR).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use half::f16;
|
||||
|
||||
/// Channel layout of an [`Image`]. Alpha, when present, is always the last
|
||||
@@ -118,11 +121,103 @@ pub struct Metadata {
|
||||
pub dpi: Option<(f32, f32)>,
|
||||
/// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags).
|
||||
pub text: Vec<(String, String)>,
|
||||
/// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here;
|
||||
/// `photocraft-io` reads and writes them through `photocraft-psd`.
|
||||
pub photoshop_resources: Option<Vec<u8>>,
|
||||
/// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept
|
||||
/// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`.
|
||||
pub photoshop_layers: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.exif.is_none() && self.xmp.is_none() && self.dpi.is_none() && self.text.is_empty()
|
||||
self.exif.is_none()
|
||||
&& self.xmp.is_none()
|
||||
&& self.dpi.is_none()
|
||||
&& self.text.is_empty()
|
||||
&& self.photoshop_resources.is_none()
|
||||
&& self.photoshop_layers.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Something a decoder noticed about the file that the decoded pixels don't show: the file
|
||||
/// held more than was decoded, or less than it should.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecodeWarning {
|
||||
/// Only the first frame of an animation was decoded; `total` counts every frame when known.
|
||||
MoreFrames { total: Option<u32> },
|
||||
/// Only the first page of a multi-page file was decoded; `total` counts every page when known.
|
||||
MorePages { total: Option<u32> },
|
||||
/// The image data ends early (truncated or damaged file); the decoder filled in the rest
|
||||
/// (a baseline JPEG's missing rows come out grey).
|
||||
Truncated { format: Format },
|
||||
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
|
||||
/// themselves (Z and other deep channels) are not part of the flat result.
|
||||
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DecodeWarning::MoreFrames { total: Some(n) } => write!(f, "only the first of {n} frames was imported"),
|
||||
DecodeWarning::MoreFrames { total: None } => write!(f, "only the first frame of the animation was imported"),
|
||||
DecodeWarning::MorePages { total: Some(n) } => write!(f, "only the first of {n} pages was imported"),
|
||||
DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"),
|
||||
DecodeWarning::Truncated { format } => {
|
||||
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
|
||||
}
|
||||
DecodeWarning::DeepFlattened { format, max_samples_per_pixel } => write!(
|
||||
f,
|
||||
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
|
||||
format = format.name()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel of a [`DeepImage`]: the samples of every pixel, one flat list in pixel order.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DeepChannel {
|
||||
/// The channel name as in the file (e.g. `R`, `G`, `B`, `A`, `Z`).
|
||||
pub name: String,
|
||||
/// The sample type as stored in the file; `samples` always holds the numeric value as
|
||||
/// f32 (half and 32-bit floats exactly; 32-bit unsigned integers exactly up to 2^24).
|
||||
pub sample: SampleType,
|
||||
/// Every sample of the channel, in pixel order; the samples of pixel `i` are
|
||||
/// `counts[i]..counts[i+1]` (see [`DeepImage::counts`]).
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Deep image data (OpenEXR `deepscanline`/`deeptile`): a variable-length list of samples
|
||||
/// per pixel, each with its own colour, alpha and depth. All channels share the same sample
|
||||
/// layout, so a sample is one index into every channel.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DeepImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The sample channels in file order.
|
||||
pub channels: Vec<DeepChannel>,
|
||||
/// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i`
|
||||
/// holds the samples `counts[i]..counts[i+1]` of every channel. Monotonically
|
||||
/// non-decreasing, starting and ending at the total sample count.
|
||||
pub counts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl DeepImage {
|
||||
/// A channel by its exact name.
|
||||
pub fn channel(&self, name: &str) -> Option<&DeepChannel> {
|
||||
self.channels.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// The samples of one pixel, as indices into every channel's `samples`.
|
||||
pub fn sample_range(&self, pixel: usize) -> std::ops::Range<u64> {
|
||||
let end = self.counts.get(pixel + 1).copied().unwrap_or(0);
|
||||
self.counts.get(pixel).copied().unwrap_or(end)..end
|
||||
}
|
||||
|
||||
/// The total number of samples over all pixels.
|
||||
pub fn total_samples(&self) -> u64 {
|
||||
self.counts.last().copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +232,9 @@ pub struct Image {
|
||||
/// Embedded ICC profile (must describe `layout`'s colour model).
|
||||
pub icc: Option<Vec<u8>>,
|
||||
pub meta: Metadata,
|
||||
/// What the decoder noticed about the file: empty for a complete, single image and for
|
||||
/// images built in memory. Encoders ignore it.
|
||||
pub warnings: Vec<DecodeWarning>,
|
||||
}
|
||||
|
||||
fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option<usize> {
|
||||
@@ -147,7 +245,7 @@ impl Image {
|
||||
/// A zero-filled image, or an error if the byte size overflows `usize`.
|
||||
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
|
||||
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default() })
|
||||
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Wrap raw interleaved, native-endian bytes.
|
||||
@@ -156,7 +254,7 @@ impl Image {
|
||||
if data.len() != expected {
|
||||
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
|
||||
}
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default() })
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
@@ -324,6 +422,7 @@ impl Image {
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
meta: self.meta.clone(),
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,17 +19,20 @@ mod fidelity;
|
||||
mod format;
|
||||
mod image;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
|
||||
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
|
||||
pub use crate::error::CodecError;
|
||||
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
|
||||
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
|
||||
pub use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, Metadata, SampleType};
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
@@ -49,7 +52,8 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
|
||||
/// Decode as a specific format.
|
||||
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
if !caps(format).read {
|
||||
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
|
||||
if !caps(format).read && format != Format::Heif {
|
||||
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
|
||||
}
|
||||
let l = &opts.limits;
|
||||
@@ -60,11 +64,38 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::WebP => webp::decode(bytes, l),
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
}?;
|
||||
// Final guard for decoders whose header we could not pre-inspect.
|
||||
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
|
||||
Ok(img)
|
||||
if opts.keep_orientation {
|
||||
return Ok(img);
|
||||
}
|
||||
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
|
||||
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way. HEIF keeps it
|
||||
// in its container, and its decoder has already applied it.
|
||||
let o = match format {
|
||||
Format::Tiff => exif_orientation(bytes),
|
||||
Format::Heif => 1,
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
|
||||
// that fit can turn into a portrait that does not, so check the upright size too.
|
||||
if (5..=8).contains(&o) {
|
||||
l.check(img.height(), img.width(), img.layout(), img.sample_type())?;
|
||||
}
|
||||
img.oriented(o)
|
||||
}
|
||||
|
||||
/// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists
|
||||
/// (colour, alpha and Z per sample), for callers that composite deep data themselves —
|
||||
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
|
||||
/// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part.
|
||||
pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
|
||||
let mut cur = std::io::Cursor::new(bytes);
|
||||
let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?;
|
||||
codecs::deep_exr::decode_deep(&meta, bytes, limits)
|
||||
}
|
||||
|
||||
/// Encode `image` as `format`. Conversions follow the same plan that
|
||||
@@ -90,6 +121,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::WebP => webp::encode(image, plan, opts),
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ impl Limits {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DecodeOptions {
|
||||
pub limits: Limits,
|
||||
/// Keep the pixels as stored instead of applying the EXIF / TIFF
|
||||
/// orientation (by default they are turned upright and the metadata's
|
||||
/// orientation is rewritten to 1).
|
||||
pub keep_orientation: bool,
|
||||
}
|
||||
|
||||
/// PNG zlib effort.
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! EXIF / TIFF orientation (tag 274): reading it, applying it to the pixels,
|
||||
//! and rewriting it to 1 ("top-left", upright) once the pixels are upright.
|
||||
//!
|
||||
//! Decoders apply the orientation by default (like Photoshop and every photo
|
||||
//! viewer), so a document is always edited upright. Encoders then write
|
||||
//! Orientation = 1, so a viewer never rotates the already-upright pixels a
|
||||
//! second time.
|
||||
//!
|
||||
//! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 =
|
||||
//! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90°
|
||||
//! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn.
|
||||
//! Anything malformed, truncated or out of range reads as 1: an IFD0 that
|
||||
//! starts inside the 8-byte header or is cut off before its next-IFD pointer,
|
||||
//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says
|
||||
//! SHORT, but some cameras and tools write LONG). When IFD0 holds
|
||||
//! several Orientation entries the first one wins (like libexif).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::image::Image;
|
||||
|
||||
/// The TIFF/EXIF Orientation tag.
|
||||
const TAG_ORIENTATION: u16 = 274;
|
||||
/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG
|
||||
/// (written by some cameras and tools).
|
||||
const TYPE_SHORT: u16 = 3;
|
||||
const TYPE_LONG: u16 = 4;
|
||||
/// Bytes per IFD entry.
|
||||
const ENTRY: usize = 12;
|
||||
/// The TIFF header is 8 bytes, so no IFD can start before it.
|
||||
const HEADER: usize = 8;
|
||||
/// BigTIFF uses a 16-byte header, 20-byte entries, and 8-byte offsets.
|
||||
const BIG_ENTRY: usize = 20;
|
||||
const BIG_HEADER: usize = 16;
|
||||
|
||||
/// Byte order of a TIFF structure.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Order {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
|
||||
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u16::from_le_bytes(s),
|
||||
Order::Big => u16::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
|
||||
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u32::from_le_bytes(s),
|
||||
Order::Big => u32::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
|
||||
fn u64(self, b: &[u8], at: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(match self {
|
||||
Order::Little => u64::from_le_bytes(s),
|
||||
Order::Big => u64::from_be_bytes(s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
|
||||
fn tiff_body(b: &[u8]) -> &[u8] {
|
||||
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
|
||||
}
|
||||
|
||||
/// IFD0 of a TIFF structure: byte order, entry layout, and entry offsets.
|
||||
///
|
||||
/// The full table and next-IFD pointer must be present, and IFD0 must start
|
||||
/// after the corresponding TIFF or BigTIFF header.
|
||||
fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
|
||||
let (order, big_tiff) = match b.get(0..4)? {
|
||||
[b'I', b'I', 42, 0] => (Order::Little, false),
|
||||
[b'M', b'M', 0, 42] => (Order::Big, false),
|
||||
[b'I', b'I', 43, 0] => (Order::Little, true),
|
||||
[b'M', b'M', 0, 43] => (Order::Big, true),
|
||||
_ => return None,
|
||||
};
|
||||
let (ifd, count_bytes, entry_bytes, next_ifd_bytes, header) = if big_tiff {
|
||||
if order.u16(b, 4)? != 8 || order.u16(b, 6)? != 0 {
|
||||
return None;
|
||||
}
|
||||
(usize::try_from(order.u64(b, 8)?).ok()?, 8usize, BIG_ENTRY, 8usize, BIG_HEADER)
|
||||
} else {
|
||||
(usize::try_from(order.u32(b, 4)?).ok()?, 2usize, ENTRY, 4usize, HEADER)
|
||||
};
|
||||
if ifd < header {
|
||||
return None;
|
||||
}
|
||||
let count = if big_tiff { usize::try_from(order.u64(b, ifd)?).ok()? } else { usize::from(order.u16(b, ifd)?) };
|
||||
let first = ifd.checked_add(count_bytes)?;
|
||||
let next_ifd = first.checked_add(count.checked_mul(entry_bytes)?)?;
|
||||
if next_ifd.checked_add(next_ifd_bytes)? > b.len() {
|
||||
return None;
|
||||
}
|
||||
// In bounds: every entry ends at or before `next_ifd`.
|
||||
Some((order, first, count, entry_bytes, big_tiff))
|
||||
}
|
||||
|
||||
/// A well-formed Orientation entry's value: where it sits and its field type.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Value {
|
||||
at: usize,
|
||||
ty: u16,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The stored value (a SHORT or LONG, left-justified in the 4-byte field).
|
||||
fn read(self, order: Order, b: &[u8]) -> Option<u32> {
|
||||
match self.ty {
|
||||
TYPE_SHORT => order.u16(b, self.at).map(u32::from),
|
||||
_ => order.u32(b, self.at),
|
||||
}
|
||||
}
|
||||
|
||||
/// 1 in this entry's own type, width and byte order.
|
||||
fn one(self, order: Order) -> Vec<u8> {
|
||||
match (self.ty, order) {
|
||||
(TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(),
|
||||
(TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(),
|
||||
(_, Order::Little) => 1u32.to_le_bytes().to_vec(),
|
||||
(_, Order::Big) => 1u32.to_be_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of the entry at `e` if it is a well-formed Orientation entry:
|
||||
/// exactly one SHORT or LONG.
|
||||
fn orientation_value(order: Order, b: &[u8], e: usize, big_tiff: bool) -> Option<Value> {
|
||||
let count = if big_tiff { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
|
||||
if order.u16(b, e)? != TAG_ORIENTATION || count != 1 {
|
||||
return None;
|
||||
}
|
||||
let ty = order.u16(b, e.checked_add(2)?)?;
|
||||
let value_offset = if big_tiff { 12 } else { 8 };
|
||||
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(value_offset)?, ty })
|
||||
}
|
||||
|
||||
/// Locates the Orientation value in IFD0.
|
||||
///
|
||||
/// The **first** Orientation entry wins (like libexif and Photoshop); if that
|
||||
/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored,
|
||||
/// even when a later duplicate is well formed.
|
||||
fn find_entry(b: &[u8]) -> Option<(Order, Value)> {
|
||||
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?;
|
||||
let e = (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
|
||||
Some((order, orientation_value(order, b, e, big_tiff)?))
|
||||
}
|
||||
|
||||
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
|
||||
/// (with or without the `Exif\0\0` prefix) or a whole TIFF file. Missing,
|
||||
/// malformed or out-of-range values give 1.
|
||||
pub fn exif_orientation(exif: &[u8]) -> u16 {
|
||||
let b = tiff_body(exif);
|
||||
find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// `exif` with its Orientation rewritten to 1, borrowed when there is nothing
|
||||
/// to change (no well-formed tag, already 1, or unparseable).
|
||||
///
|
||||
/// Only the value bytes of each well-formed Orientation entry change, written
|
||||
/// as 1 in the entry's existing type and width (SHORT or LONG); every other
|
||||
/// byte (other tags, their offset-based values, sub-IFDs) is kept as is.
|
||||
/// Well-formed duplicates are all set to 1, so a reader that lets a later
|
||||
/// duplicate win cannot rotate the upright pixels either.
|
||||
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let Some((order, first, count, entry_bytes, big_tiff)) = ifd0_entries(body) else {
|
||||
return Cow::Borrowed(exif);
|
||||
};
|
||||
let values: Vec<Value> =
|
||||
(0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).filter_map(|e| orientation_value(order, body, e, big_tiff)).collect();
|
||||
if values.iter().all(|v| v.read(order, body) == Some(1)) {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let mut out = exif.to_vec();
|
||||
for v in values {
|
||||
let one = v.one(order);
|
||||
let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?));
|
||||
if let Some(slot) = slot {
|
||||
slot.copy_from_slice(&one);
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
|
||||
/// borrowed when there is nothing to change.
|
||||
pub fn upright_xmp(xmp: &str) -> Cow<'_, str> {
|
||||
const KEY: &str = "tiff:Orientation";
|
||||
if !xmp.contains(KEY) {
|
||||
return Cow::Borrowed(xmp);
|
||||
}
|
||||
let mut out = String::with_capacity(xmp.len());
|
||||
let mut rest = xmp;
|
||||
let mut changed = false;
|
||||
while let Some(i) = rest.find(KEY) {
|
||||
let (head, tail) = rest.split_at(i + KEY.len());
|
||||
out.push_str(head);
|
||||
rest = tail;
|
||||
// `tiff:Orientation="6"`, `tiff:Orientation='6'` or `<tiff:Orientation>6<`.
|
||||
let open = rest.char_indices().take_while(|&(_, c)| c.is_ascii_whitespace() || matches!(c, '=' | '"' | '\'' | '>')).last();
|
||||
let Some((at, c)) = open else { continue };
|
||||
if !matches!(c, '"' | '\'' | '>') {
|
||||
continue;
|
||||
}
|
||||
let start = at + 1;
|
||||
let digits = rest.get(start..).map_or(0, |s| s.bytes().take_while(u8::is_ascii_digit).count());
|
||||
if digits == 0 || rest.get(start..start + digits) == Some("1") {
|
||||
continue;
|
||||
}
|
||||
out.push_str(rest.get(..start).unwrap_or_default());
|
||||
out.push('1');
|
||||
rest = rest.get(start + digits..).unwrap_or_default();
|
||||
changed = true;
|
||||
}
|
||||
out.push_str(rest);
|
||||
if changed { Cow::Owned(out) } else { Cow::Borrowed(xmp) }
|
||||
}
|
||||
|
||||
/// Source pixel index for output pixel `(0, y2)` and the step per output column, for
|
||||
/// orientation `o` of a `w`×`h` source (values 2–8; see the module docs).
|
||||
fn walk(o: u16, w: isize, h: isize, y2: isize) -> (isize, isize) {
|
||||
match o {
|
||||
2 => (y2 * w + w - 1, -1),
|
||||
3 => ((h - 1 - y2) * w + w - 1, -1),
|
||||
4 => ((h - 1 - y2) * w, 1),
|
||||
5 => (y2, w),
|
||||
6 => ((h - 1) * w + y2, -w),
|
||||
7 => ((h - 1) * w + (w - 1 - y2), -w),
|
||||
8 => (w - 1 - y2, w),
|
||||
_ => (y2 * w, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output columns per block: a block's source pixels (for a 90° turn, `BLOCK`
|
||||
/// rows × the band height) stay in cache while it is written.
|
||||
const BLOCK: usize = 64;
|
||||
/// Output rows per parallel band.
|
||||
const BAND: usize = 64;
|
||||
|
||||
/// Writes the oriented pixels of one band of output rows, `N` bytes per pixel.
|
||||
fn band<const N: usize>(src: &[[u8; N]], dst: &mut [[u8; N]], o: u16, (w, h): (usize, usize), ow: usize, y0: usize) {
|
||||
let (wi, hi) = (w as isize, h as isize);
|
||||
let rows = dst.len() / ow.max(1);
|
||||
for x0 in (0..ow).step_by(BLOCK) {
|
||||
let x1 = (x0 + BLOCK).min(ow);
|
||||
for r in 0..rows {
|
||||
let (base, step) = walk(o, wi, hi, (y0 + r) as isize);
|
||||
let Some(out) = dst.get_mut(r * ow + x0..r * ow + x1) else { return };
|
||||
let mut i = base + x0 as isize * step;
|
||||
for px in out {
|
||||
if let Some(s) = usize::try_from(i).ok().and_then(|i| src.get(i)) {
|
||||
*px = *s;
|
||||
}
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orients `src` (`N` bytes per pixel) into a new buffer, a band of rows per task.
|
||||
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = alloc_zeroed(src.len())?;
|
||||
let (s, _) = src.as_chunks::<N>();
|
||||
let (d, _) = out.as_chunks_mut::<N>();
|
||||
let per = (BAND * ow).max(1);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
d.par_chunks_mut(per).enumerate().for_each(|(b, chunk)| band(s, chunk, o, (w, h), ow, b * BAND));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (b, chunk) in d.chunks_mut(per).enumerate() {
|
||||
band(s, chunk, o, (w, h), ow, b * BAND);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A zeroed buffer of `n` bytes, or an error (not an abort) when memory runs out.
|
||||
fn alloc_zeroed(n: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?;
|
||||
out.resize(n, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pixel-format-agnostic orientation: any byte size per pixel.
|
||||
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
match bpp {
|
||||
1 => orient_n::<1>(src, o, (w, h), ow),
|
||||
2 => orient_n::<2>(src, o, (w, h), ow),
|
||||
3 => orient_n::<3>(src, o, (w, h), ow),
|
||||
4 => orient_n::<4>(src, o, (w, h), ow),
|
||||
5 => orient_n::<5>(src, o, (w, h), ow),
|
||||
6 => orient_n::<6>(src, o, (w, h), ow),
|
||||
8 => orient_n::<8>(src, o, (w, h), ow),
|
||||
10 => orient_n::<10>(src, o, (w, h), ow),
|
||||
12 => orient_n::<12>(src, o, (w, h), ow),
|
||||
16 => orient_n::<16>(src, o, (w, h), ow),
|
||||
_ => orient_n::<20>(src, o, (w, h), ow),
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// The image turned upright for orientation `o` (1–8; anything else is
|
||||
/// treated as 1 and returns the image unchanged). Width and height swap
|
||||
/// for 5–8, as does the DPI, and the EXIF and XMP orientation are
|
||||
/// rewritten to 1 so the result is not rotated again on display or export.
|
||||
pub fn oriented(self, o: u16) -> Result<Image, CodecError> {
|
||||
if !(2..=8).contains(&o) {
|
||||
return Ok(self);
|
||||
}
|
||||
let (w, h) = (self.width() as usize, self.height() as usize);
|
||||
let swap = o >= 5;
|
||||
let (ow, oh) = if swap { (h, w) } else { (w, h) };
|
||||
let (layout, sample) = (self.layout(), self.sample_type());
|
||||
let bpp = layout.channels() * sample.bytes();
|
||||
// Every layout/sample pair is 1–20 bytes per pixel and has a fixed-size path.
|
||||
if !matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0 {
|
||||
return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}")));
|
||||
}
|
||||
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
|
||||
let mut meta = self.meta.clone();
|
||||
if let Some(e) = &mut meta.exif
|
||||
&& let Cow::Owned(fixed) = upright_exif(e)
|
||||
{
|
||||
*e = fixed;
|
||||
}
|
||||
if let Some(x) = &mut meta.xmp
|
||||
&& let Cow::Owned(fixed) = upright_xmp(x)
|
||||
{
|
||||
*x = fixed;
|
||||
}
|
||||
if swap {
|
||||
meta.dpi = meta.dpi.map(|(x, y)| (y, x));
|
||||
}
|
||||
let mut out = Image::from_raw(ow as u32, oh as u32, layout, sample, data)?.with_icc(self.icc).with_meta(meta);
|
||||
out.warnings = self.warnings;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_impossible_allocation_is_an_error_not_an_abort() {
|
||||
assert!(matches!(alloc_zeroed(usize::MAX), Err(CodecError::LimitExceeded(_))));
|
||||
assert!(matches!(orient_n::<1>(&[], 6, (0, 0), 0), Ok(v) if v.is_empty()));
|
||||
assert_eq!(alloc_zeroed(3).unwrap(), vec![0, 0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Decode warnings: a file that holds more than was decoded (#523: animations, multi-page
|
||||
//! TIFF) or less than it should (#518: truncated JPEG) says so; complete single images don't.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const W: u32 = 24;
|
||||
const H: u32 = 16;
|
||||
|
||||
fn solid(rgb: [u8; 3]) -> Image {
|
||||
Image::from_u8(W, H, ChannelLayout::Rgb, rgb.repeat((W * H) as usize)).unwrap()
|
||||
}
|
||||
|
||||
const COLOURS: [[u8; 3]; 3] = [[200, 30, 10], [10, 200, 30], [30, 10, 200]];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #518: truncated JPEG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn jpeg(layout: ChannelLayout, progressive: bool) -> Vec<u8> {
|
||||
let img = synth(64, 48, layout, SampleType::U8, 3, 0.3);
|
||||
let ct = match layout {
|
||||
ChannelLayout::Gray => jpeg_encoder::ColorType::Luma,
|
||||
ChannelLayout::Cmyk => jpeg_encoder::ColorType::Cmyk,
|
||||
_ => jpeg_encoder::ColorType::Rgb,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut out, 90);
|
||||
enc.set_progressive(progressive);
|
||||
enc.encode(img.data(), 64, 48, ct).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// Offset of the first byte of entropy-coded data (just past the first SOS header).
|
||||
fn scan_start(b: &[u8]) -> usize {
|
||||
let sos = b.windows(2).position(|w| w == [0xFF, 0xDA]).unwrap();
|
||||
sos + 2 + u16::from_be_bytes([b[sos + 2], b[sos + 3]]) as usize
|
||||
}
|
||||
|
||||
const TRUNCATED: DecodeWarning = DecodeWarning::Truncated { format: Format::Jpeg };
|
||||
|
||||
#[test]
|
||||
fn truncated_jpeg_decodes_with_a_warning() {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Cmyk] {
|
||||
for progressive in [false, true] {
|
||||
let full = jpeg(layout, progressive);
|
||||
assert_eq!(decode(&full).unwrap().warnings, [], "{layout:?} progressive {progressive}");
|
||||
let half = decode(&full[..full.len() / 2]).unwrap();
|
||||
assert_eq!(half.warnings, [TRUNCATED], "{layout:?} progressive {progressive}");
|
||||
assert_eq!(half.dimensions(), (64, 48));
|
||||
// One byte short: the end-of-image marker is incomplete.
|
||||
assert_eq!(decode(&full[..full.len() - 1]).unwrap().warnings, [TRUNCATED]);
|
||||
// A few bytes of scan data still decode, with the warning.
|
||||
let start = scan_start(&full);
|
||||
assert_eq!(decode(&full[..start + 8]).unwrap().warnings, [TRUNCATED]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_jpeg_message_names_the_problem() {
|
||||
let s = TRUNCATED.to_string();
|
||||
assert!(s.starts_with("JPEG data ends early") && s.contains("truncated"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_without_any_scan_data_is_an_error() {
|
||||
let full = jpeg(ChannelLayout::Rgb, false);
|
||||
let start = scan_start(&full);
|
||||
for cut in [start - 3, start - 1, start] {
|
||||
let e = decode(&full[..cut]).unwrap_err().to_string();
|
||||
assert!(e.contains("before any image data"), "cut {cut}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_after_the_end_of_image_is_not_truncation() {
|
||||
let mut b = jpeg(ChannelLayout::Rgb, false);
|
||||
b.extend_from_slice(b"\0\0trailing bytes \xFF\xD8\xFF");
|
||||
assert_eq!(decode(&b).unwrap().warnings, []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_marker_inside_metadata_does_not_hide_truncation() {
|
||||
// An EXIF block holding FF D9 (as an embedded thumbnail does) must be skipped as a segment.
|
||||
let mut img = synth(64, 48, ChannelLayout::Rgb, SampleType::U8, 3, 0.3);
|
||||
let mut exif = sample_exif();
|
||||
exif.extend_from_slice(&[0xFF, 0xD8, 0x00, 0xFF, 0xD9]);
|
||||
img.meta.exif = Some(exif);
|
||||
let full = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(decode(&full).unwrap().warnings, []);
|
||||
assert_eq!(decode(&full[..full.len() * 2 / 3]).unwrap().warnings, [TRUNCATED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_markers_and_fill_bytes_are_scan_data() {
|
||||
// Restart markers (FF D0–D7) and fill bytes (FF FF) inside a scan don't end it.
|
||||
let full = jpeg(ChannelLayout::Rgb, false);
|
||||
let start = scan_start(&full);
|
||||
let mut b = full[..start].to_vec();
|
||||
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56, 0xFF, 0xFF, 0xD7, 0x78]);
|
||||
assert_eq!(decode(&b).unwrap().warnings, [TRUNCATED]);
|
||||
b.extend_from_slice(&[0xFF, 0xFF, 0xD9]);
|
||||
assert_eq!(decode(&b).unwrap().warnings, []);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #523: only the first frame or page is decoded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn more_frames(n: u32) -> Vec<DecodeWarning> {
|
||||
vec![DecodeWarning::MoreFrames { total: Some(n) }]
|
||||
}
|
||||
|
||||
fn gif(frames: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = image::codecs::gif::GifEncoder::new(&mut out);
|
||||
let frames = COLOURS.iter().take(frames).map(|c| image::Frame::new(image::RgbaImage::from_pixel(W, H, image::Rgba([c[0], c[1], c[2], 255]))));
|
||||
enc.encode_frames(frames).unwrap();
|
||||
drop(enc);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_gif_warns_and_keeps_the_first_frame() {
|
||||
let one = decode(&gif(1)).unwrap();
|
||||
assert_eq!(one.warnings, []);
|
||||
for n in [2, 3] {
|
||||
let img = decode(&gif(n)).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(n as u32));
|
||||
assert_eq!(img.to_rgba8(), one.to_rgba8(), "{n} frames: first frame pixels");
|
||||
}
|
||||
// A single-frame GIF written by our own encoder.
|
||||
let ours = encode(&solid(COLOURS[0]), Format::Gif, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(decode(&ours).unwrap().warnings, []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_without_a_trailer_warns_without_a_total() {
|
||||
let b = gif(3);
|
||||
assert_eq!(b.last(), Some(&0x3B));
|
||||
let img = decode(&b[..b.len() - 1]).unwrap();
|
||||
assert_eq!(img.warnings, [DecodeWarning::MoreFrames { total: None }]);
|
||||
assert_eq!(img.warnings[0].to_string(), "only the first frame of the animation was imported");
|
||||
}
|
||||
|
||||
/// An APNG of `frames` solid frames at 8 or 16 bits; with `separate_default` the IDAT image is
|
||||
/// an extra default image outside the animation.
|
||||
fn apng(frames: u32, sixteen: bool, separate_default: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, W, H);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.set_depth(if sixteen { png::BitDepth::Sixteen } else { png::BitDepth::Eight });
|
||||
let images = frames + u32::from(separate_default);
|
||||
if frames > 0 {
|
||||
enc.set_animated(frames, 0).unwrap();
|
||||
enc.set_sep_def_img(separate_default).unwrap();
|
||||
}
|
||||
let mut w = enc.write_header().unwrap();
|
||||
for c in COLOURS.iter().cycle().take(images.max(1) as usize) {
|
||||
let px: Vec<u8> = if sixteen { c.iter().flat_map(|&v| [v, v]).collect::<Vec<u8>>().repeat((W * H) as usize) } else { c.repeat((W * H) as usize) };
|
||||
w.write_image_data(&px).unwrap();
|
||||
}
|
||||
w.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apng_warns_and_keeps_the_first_frame() {
|
||||
for sixteen in [false, true] {
|
||||
let still = decode(&apng(0, sixteen, false)).unwrap();
|
||||
assert_eq!(still.warnings, []);
|
||||
assert_eq!(decode(&apng(1, sixteen, false)).unwrap().warnings, [], "a one-frame APNG is a still");
|
||||
let img = decode(&apng(3, sixteen, false)).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(3), "16-bit {sixteen}");
|
||||
assert_eq!((img.sample_type(), img.data()), (still.sample_type(), still.data()), "16-bit {sixteen}: first frame pixels");
|
||||
// Default image + 2 animation frames: three images, the default one kept.
|
||||
let img = decode(&apng(2, sixteen, true)).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(3));
|
||||
assert_eq!(img.data(), still.data());
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn u24(v: u32) -> [u8; 3] {
|
||||
let b = v.to_le_bytes();
|
||||
[b[0], b[1], b[2]]
|
||||
}
|
||||
|
||||
/// An animated WebP: each frame is a full-canvas lossless still wrapped in an ANMF chunk.
|
||||
fn animated_webp(frames: &[Image]) -> Vec<u8> {
|
||||
let mut body = b"WEBP".to_vec();
|
||||
let mut vp8x = vec![0x02, 0, 0, 0]; // animation flag
|
||||
vp8x.extend_from_slice(&u24(W - 1));
|
||||
vp8x.extend_from_slice(&u24(H - 1));
|
||||
body.extend(chunk(b"VP8X", &vp8x));
|
||||
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]));
|
||||
for f in frames {
|
||||
let still = encode(f, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let at = still.windows(4).position(|w| w == b"VP8L").unwrap();
|
||||
let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let mut anmf = vec![0; 6]; // frame x, y
|
||||
anmf.extend_from_slice(&u24(W - 1));
|
||||
anmf.extend_from_slice(&u24(H - 1));
|
||||
anmf.extend_from_slice(&u24(100)); // duration
|
||||
anmf.push(0);
|
||||
anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len]));
|
||||
body.extend(chunk(b"ANMF", &anmf));
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_webp_warns_and_keeps_the_first_frame() {
|
||||
let frames: Vec<Image> = COLOURS.iter().map(|&c| solid(c)).collect();
|
||||
let still = decode(&encode(&frames[0], Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(still.warnings, []);
|
||||
assert_eq!(decode(&animated_webp(&frames[..1])).unwrap().warnings, [], "a one-frame animation");
|
||||
for n in [2, 3] {
|
||||
let img = decode(&animated_webp(&frames[..n])).unwrap();
|
||||
assert_eq!(img.warnings, more_frames(n as u32));
|
||||
// The animation decoder composites the frame onto its canvas: within one level.
|
||||
let (a, b) = (img.to_rgba8(), still.to_rgba8());
|
||||
assert!(a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| x.abs_diff(*y) <= 1), "{n} frames: first frame pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF with one IFD per entry: `(NewSubfileType, sample type)`.
|
||||
fn tiff(ifds: &[(u32, SampleType)]) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
use tiff::tags::Tag;
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut out).unwrap();
|
||||
let n = (W * H) as usize;
|
||||
// Each page is a flat grey at its own level (`k`), written at `sample` depth.
|
||||
for (k, &(kind, sample)) in ifds.iter().enumerate() {
|
||||
macro_rules! page {
|
||||
($ct:ty, $data:expr) => {{
|
||||
let mut im = enc.new_image::<$ct>(W, H).unwrap();
|
||||
if kind != 0 {
|
||||
im.encoder().write_tag(Tag::NewSubfileType, kind).unwrap();
|
||||
}
|
||||
im.write_data($data).unwrap();
|
||||
}};
|
||||
}
|
||||
let v = 40 * k as u8 + 10;
|
||||
match sample {
|
||||
SampleType::U8 => page!(colortype::Gray8, &vec![v; n]),
|
||||
SampleType::U16 => page!(colortype::Gray16, &vec![u16::from(v) * 257; n]),
|
||||
_ => page!(colortype::Gray32Float, &vec![f32::from(v) / 255.0; n]),
|
||||
}
|
||||
}
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_page_tiff_warns_and_keeps_the_first_page() {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let one = decode(&tiff(&[(0, sample)])).unwrap();
|
||||
assert_eq!(one.warnings, [], "{sample:?}");
|
||||
let img = decode(&tiff(&[(0, sample), (2, sample)])).unwrap();
|
||||
assert_eq!(img.warnings, [DecodeWarning::MorePages { total: Some(2) }], "{sample:?}");
|
||||
assert_eq!(img.data(), one.data(), "{sample:?}: first page pixels");
|
||||
let img = decode(&tiff(&[(0, sample), (0, sample), (0, sample)])).unwrap();
|
||||
assert_eq!(img.warnings, [DecodeWarning::MorePages { total: Some(3) }], "{sample:?}");
|
||||
assert_eq!(img.warnings[0].to_string(), "only the first of 3 pages was imported");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_thumbnails_and_masks_are_not_pages() {
|
||||
let u8 = SampleType::U8;
|
||||
// A reduced-resolution copy (bit 0) or a transparency mask (bit 2) is part of the page.
|
||||
assert_eq!(decode(&tiff(&[(0, u8), (1, u8)])).unwrap().warnings, []);
|
||||
assert_eq!(decode(&tiff(&[(0, u8), (4, u8)])).unwrap().warnings, []);
|
||||
assert_eq!(decode(&tiff(&[(0, u8), (1, u8), (0, u8)])).unwrap().warnings, [DecodeWarning::MorePages { total: Some(2) }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_images_carry_no_warnings() {
|
||||
for f in rw_formats() {
|
||||
let img = synth(9, 7, ChannelLayout::Rgba, SampleType::U8, 5, 0.2);
|
||||
let Ok(bytes) = encode(&img, f, &EncodeOptions::default()) else { continue };
|
||||
assert_eq!(decode(&bytes).unwrap().warnings, [], "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_survive_orientation() {
|
||||
let mut img = synth(8, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.warnings.push(DecodeWarning::MorePages { total: None });
|
||||
for o in 1..=8 {
|
||||
assert_eq!(img.clone().oriented(o).unwrap().warnings, img.warnings, "orientation {o}");
|
||||
}
|
||||
assert_eq!(img.convert(ChannelLayout::Rgba, SampleType::U16).warnings, img.warnings);
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
//! Deep EXR: a synthetic generator builds deep chunks per the OpenEXR file layout
|
||||
//! specification (NONE, RLE and ZIPS × scanline and tile), the oracle tests check the
|
||||
//! compositing math against hand-computed values, and malformed inputs must error, never
|
||||
//! panic. Real files from `corpus/exr/` (copied in by hand, not pinned) are checked when
|
||||
//! they exist.
|
||||
|
||||
use photocraft_codecs::CodecError;
|
||||
use photocraft_codecs::f16;
|
||||
use photocraft_codecs::{Limits, decode, decode_deep_exr};
|
||||
use std::io::Write as _;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A minimal deep-EXR writer, mirroring what the reference files show on disk:
|
||||
// version 2 | deep bit (and the tile bit for tiles), one header with the mandatory
|
||||
// attributes in alphabetical order, a chunk offset table, then deep chunks of
|
||||
// [y | tile coords][u64 packed table][u64 packed data][u64 unpacked data][table][data].
|
||||
// The table holds u32 sample counts, cumulative within each row of the block; the sample
|
||||
// data is grouped per row, then per channel, in pixel order (the layout the OpenEXR library
|
||||
// writes). NONE stores raw; RLE and ZIPS separate even/odd bytes, apply the +128 predictor,
|
||||
// then RLE packs runs and ZIPS deflates.
|
||||
|
||||
fn attr(out: &mut Vec<u8>, name: &str, ty: &str, payload: &[u8]) {
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.push(0);
|
||||
out.extend_from_slice(ty.as_bytes());
|
||||
out.push(0);
|
||||
out.extend_from_slice(&(payload.len() as i32).to_le_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
}
|
||||
|
||||
fn samples_to_differences(buf: &mut [u8]) {
|
||||
if let Some(&first) = buf.first() {
|
||||
let mut prev = i16::from(first);
|
||||
for b in buf.iter_mut().skip(1) {
|
||||
let v = i16::from(*b);
|
||||
*b = (v - prev + 128) as u8;
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn separate_bytes_fragments(buf: &mut [u8]) {
|
||||
let copy = buf.to_vec();
|
||||
let half = copy.len().div_ceil(2);
|
||||
for i in 0..copy.len() {
|
||||
buf[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = copy[i];
|
||||
}
|
||||
}
|
||||
|
||||
fn rle_bytes(src: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
let mut run = 1;
|
||||
while i + run < src.len() && run < 128 && src[i + run] == src[i] {
|
||||
run += 1;
|
||||
}
|
||||
// OpenEXR tokens: `n >= 0` repeats the next byte n + 1 times, `n < 0` copies -n bytes.
|
||||
if run > 1 {
|
||||
out.push((run - 1) as u8);
|
||||
out.push(src[i]);
|
||||
i += run;
|
||||
} else {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < src.len() && (i - start) < 127 && src[i] != src[i - 1] {
|
||||
i += 1;
|
||||
}
|
||||
let n = i - start;
|
||||
out.push((-(n as i32)) as u8);
|
||||
out.extend_from_slice(&src[start..start + n]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn compress(kind: u8, raw: &[u8]) -> Vec<u8> {
|
||||
match kind {
|
||||
1 => {
|
||||
// Like ZIPS, RLE packs the byte-split, predicted stream.
|
||||
let mut b = raw.to_vec();
|
||||
separate_bytes_fragments(&mut b);
|
||||
samples_to_differences(&mut b);
|
||||
let c = rle_bytes(&b);
|
||||
if c.len() < raw.len() { c } else { raw.to_vec() }
|
||||
}
|
||||
2 => {
|
||||
let mut b = raw.to_vec();
|
||||
separate_bytes_fragments(&mut b);
|
||||
samples_to_differences(&mut b);
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
// Writing to a Vec never fails; the encoder errors surface in `finish`.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
z.write_all(&b).unwrap();
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let c = z.finish().unwrap();
|
||||
if c.len() < raw.len() { c } else { raw.to_vec() }
|
||||
}
|
||||
_ => raw.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel: a name (the caller must pass channels in alphabetical order, as chlist
|
||||
/// requires) and the sample type as in the chlist attribute (0 = u32, 1 = half, 2 = float).
|
||||
struct Chan {
|
||||
name: &'static str,
|
||||
ty: i32,
|
||||
}
|
||||
|
||||
/// `counts[p]` samples per pixel; `sample(p, ci)` returns channel `ci`'s values for pixel
|
||||
/// `p` (its length must be `counts[p]`). `tile` switches between deepscanline and deeptile.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn gen_deep(
|
||||
w: usize,
|
||||
h: usize,
|
||||
channels: &[Chan],
|
||||
compression: u8,
|
||||
tile: Option<(usize, usize)>,
|
||||
counts: &[u32],
|
||||
sample: &dyn Fn(usize, usize) -> Vec<f32>,
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
|
||||
// Single-part deep files (deepscanline and deeptile alike) carry only the deep-data
|
||||
// flag; the tiling lives in the `tiles` attribute and the `type` string.
|
||||
out.extend_from_slice(&(2u32 | 0x800).to_le_bytes());
|
||||
|
||||
let mut chlist = Vec::new();
|
||||
for c in channels {
|
||||
chlist.extend_from_slice(c.name.as_bytes());
|
||||
chlist.push(0);
|
||||
chlist.extend_from_slice(&c.ty.to_le_bytes());
|
||||
chlist.push(0); // pLinear
|
||||
chlist.extend_from_slice(&[0u8; 3]);
|
||||
chlist.extend_from_slice(&1i32.to_le_bytes());
|
||||
chlist.extend_from_slice(&1i32.to_le_bytes());
|
||||
}
|
||||
chlist.push(0);
|
||||
attr(&mut out, "channels", "chlist", &chlist);
|
||||
attr(&mut out, "compression", "compression", &[compression]);
|
||||
// box2i is inclusive: the data window of a w×h image at the origin ends at (w-1, h-1).
|
||||
let dw: Vec<u8> = [0i32, 0, w as i32 - 1, h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
attr(&mut out, "dataWindow", "box2i", &dw);
|
||||
attr(&mut out, "displayWindow", "box2i", &dw);
|
||||
attr(&mut out, "lineOrder", "lineOrder", &[0]);
|
||||
attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes());
|
||||
attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat());
|
||||
attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes());
|
||||
if let Some((tw, th)) = tile {
|
||||
let mut td = Vec::new();
|
||||
td.extend_from_slice(&(tw as u32).to_le_bytes());
|
||||
td.extend_from_slice(&(th as u32).to_le_bytes());
|
||||
td.push(0); // one level, round down
|
||||
attr(&mut out, "tiles", "tiledesc", &td);
|
||||
}
|
||||
attr(&mut out, "type", "string", if tile.is_some() { b"deeptile" } else { b"deepscanline" });
|
||||
attr(&mut out, "version", "int", &1i32.to_le_bytes());
|
||||
out.push(0); // end of the header
|
||||
|
||||
// The blocks: scan lines (one line each) or the level-0 tiles.
|
||||
struct Block {
|
||||
pixels: Vec<usize>,
|
||||
/// Pixels per row: the count table restarts and the sample data is grouped per row.
|
||||
row_w: usize,
|
||||
}
|
||||
let blocks: Vec<Block> = match tile {
|
||||
None => (0..h).map(|y| Block { pixels: (y * w..y * w + w).collect(), row_w: w }).collect(),
|
||||
Some((tw, th)) => {
|
||||
let tx = w.div_ceil(tw);
|
||||
let ty = h.div_ceil(th);
|
||||
let mut v = Vec::new();
|
||||
for by in 0..ty {
|
||||
for bx in 0..tx {
|
||||
let mut px = Vec::new();
|
||||
for yy in (by * th)..((by * th + th).min(h)) {
|
||||
for xx in (bx * tw)..((bx * tw + tw).min(w)) {
|
||||
px.push(yy * w + xx);
|
||||
}
|
||||
}
|
||||
v.push(Block { pixels: px, row_w: (bx * tw + tw).min(w) - bx * tw });
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
|
||||
let table_len = |b: &Block| b.pixels.len() * 4;
|
||||
let data_len = |b: &Block| -> usize {
|
||||
let samples: u64 = b.pixels.iter().map(|&p| counts[p] as u64).sum();
|
||||
channels.iter().map(|c| samples as usize * if c.ty == 1 { 2 } else { 4 }).sum()
|
||||
};
|
||||
|
||||
// Build every chunk, remember the offsets, then write the offset table first.
|
||||
let mut chunks: Vec<(usize, Vec<u8>)> = Vec::new(); // (block index, framed chunk)
|
||||
for (bi, b) in blocks.iter().enumerate() {
|
||||
let mut c = Vec::new();
|
||||
match tile {
|
||||
None => {
|
||||
let y = (bi % h) as i32;
|
||||
c.extend_from_slice(&y.to_le_bytes());
|
||||
}
|
||||
Some((tw, _)) => {
|
||||
let tx = w.div_ceil(tw);
|
||||
let (bx, by) = (bi % tx, bi / tx);
|
||||
c.extend_from_slice(&(bx as i32).to_le_bytes());
|
||||
c.extend_from_slice(&(by as i32).to_le_bytes());
|
||||
c.extend_from_slice(&0i32.to_le_bytes());
|
||||
c.extend_from_slice(&0i32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut table = Vec::with_capacity(b.pixels.len() * 4);
|
||||
for row in b.pixels.chunks(b.row_w) {
|
||||
let mut acc = 0u32;
|
||||
for &p in row {
|
||||
acc += counts[p];
|
||||
table.extend_from_slice(&acc.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for row in b.pixels.chunks(b.row_w) {
|
||||
for (ci, ch) in channels.iter().enumerate() {
|
||||
for &p in row {
|
||||
for v in sample(p, ci) {
|
||||
match ch.ty {
|
||||
1 => data.extend_from_slice(&f16::from_f32(v).to_le_bytes()),
|
||||
0 => data.extend_from_slice(&(v as u32).to_le_bytes()),
|
||||
_ => data.extend_from_slice(&v.to_le_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let ct = compress(compression, &table);
|
||||
let cd = compress(compression, &data);
|
||||
c.extend_from_slice(&(ct.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&(cd.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&ct);
|
||||
c.extend_from_slice(&cd);
|
||||
assert_eq!(table.len(), table_len(b));
|
||||
assert_eq!(data.len(), data_len(b));
|
||||
chunks.push((bi, c));
|
||||
}
|
||||
let mut pos = out.len() + chunks.len() * 8;
|
||||
let mut table = Vec::new();
|
||||
for (_, c) in &chunks {
|
||||
table.extend_from_slice(&(pos as u64).to_le_bytes());
|
||||
pos += c.len();
|
||||
}
|
||||
out.extend_from_slice(&table);
|
||||
for (_, c) in chunks {
|
||||
out.extend_from_slice(&c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// RGBA(Z) float channels in chlist (alphabetical) order.
|
||||
const RGBAZ: [Chan; 5] =
|
||||
[Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }];
|
||||
|
||||
fn sample_rgba(p: usize, ci: usize) -> Vec<f32> {
|
||||
// One sample per pixel: a small checkerboard-ish pattern with varying depth. Deep
|
||||
// colours are premultiplied, so the stored RGB values include the 0.5 alpha.
|
||||
let v = [0.25f32, 0.5, 0.75, 1.0][p % 4];
|
||||
match ci {
|
||||
0 => vec![0.5], // A
|
||||
1 => vec![0.125 * v], // B = straight 0.25v, premultiplied
|
||||
2 => vec![0.25 * v], // G = straight 0.5v, premultiplied
|
||||
3 => vec![0.5 * v], // R = straight v, premultiplied
|
||||
_ => vec![10.0 + p as f32], // Z
|
||||
}
|
||||
}
|
||||
|
||||
const COUNTS_ONE: &[u32] = &[1; 16]; // for a 4x4 image
|
||||
|
||||
fn rgba_flat(bytes: &[u8]) -> Vec<f32> {
|
||||
let img = decode(bytes).expect("decodes");
|
||||
let s = img.to_f32_samples().expect("f32");
|
||||
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba);
|
||||
assert_eq!(img.sample_type(), photocraft_codecs::SampleType::F32);
|
||||
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })));
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Oracles
|
||||
|
||||
/// One sample per pixel: the flat image is the stored colour un-premultiplied.
|
||||
#[test]
|
||||
fn single_sample_matches_flat() {
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
|
||||
let px = rgba_flat(&bytes);
|
||||
for p in 0..16 {
|
||||
let v = [0.25f32, 0.5, 0.75, 1.0][p % 4];
|
||||
let (r, g, b, a) = (px[p * 4], px[p * 4 + 1], px[p * 4 + 2], px[p * 4 + 3]);
|
||||
assert!((r - v).abs() < 1e-6, "r at {p}: {r}");
|
||||
assert!((g - 0.5 * v).abs() < 1e-6, "g at {p}: {g}");
|
||||
assert!((b - 0.25 * v).abs() < 1e-6, "b at {p}: {b}");
|
||||
assert!((a - 0.5).abs() < 1e-6, "a at {p}: {a}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two samples stored back-to-front: the front (smaller Z) covers the back. Hand-computed:
|
||||
/// front a=0.5, premultiplied colour (0.5, 0.25, 0); back a=0.25, premultiplied (0.25, 0.25, 0.25).
|
||||
/// Over (front first): a = 0.5 + (1−0.5)·0.25 = 0.625; c = (0.5 + 0.5·0.25, 0.25 + 0.5·0.25,
|
||||
/// 0 + 0.5·0.25) = (0.625, 0.375, 0.125); straight = c / a = (1.0, 0.6, 0.2).
|
||||
#[test]
|
||||
fn z_order_composites_front_over_back() {
|
||||
// pixel 0: samples [(z=9, back), (z=1, front)] — the file order is reversed on purpose.
|
||||
let counts = vec![2u32; 16];
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> {
|
||||
if p != 0 {
|
||||
let v = sample_rgba(p, ci);
|
||||
return vec![v[0], v[0]];
|
||||
}
|
||||
match ci {
|
||||
0 => vec![0.25, 0.5], // A: back first
|
||||
1 => vec![0.25, 0.0], // B
|
||||
2 => vec![0.25, 0.25], // G
|
||||
3 => vec![0.25, 0.5], // R
|
||||
_ => vec![9.0, 1.0], // Z
|
||||
}
|
||||
};
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
|
||||
let px = rgba_flat(&bytes);
|
||||
let a = 0.5f32 + 0.5 * 0.25;
|
||||
assert!((px[3] - a).abs() < 1e-6, "alpha {}", px[3]);
|
||||
assert!((px[0] - 0.625 / a).abs() < 1e-6, "r {}", px[0]);
|
||||
assert!((px[1] - 0.375 / a).abs() < 1e-6, "g {}", px[1]);
|
||||
assert!((px[2] - 0.125 / a).abs() < 1e-6, "b {}", px[2]);
|
||||
}
|
||||
|
||||
/// A fully opaque front sample hides everything behind it.
|
||||
#[test]
|
||||
fn opaque_front_hides_back() {
|
||||
let counts = vec![3u32; 16];
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> {
|
||||
if p != 1 {
|
||||
let v = sample_rgba(p, ci);
|
||||
return vec![v[0], v[0], v[0]];
|
||||
}
|
||||
match ci {
|
||||
0 => vec![1.0, 0.9, 1.0], // front opaque, middle partial, back opaque
|
||||
1 => vec![0.0, 1.0, 1.0],
|
||||
2 => vec![1.0, 1.0, 1.0],
|
||||
3 => vec![1.0, 0.0, 0.0],
|
||||
_ => vec![1.0, 5.0, 9.0],
|
||||
}
|
||||
};
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
|
||||
let px = rgba_flat(&bytes);
|
||||
let (r, g, b, a) = (px[4], px[5], px[6], px[7]);
|
||||
assert!((r - 1.0).abs() < 1e-6 && (g - 1.0).abs() < 1e-6 && (b - 0.0).abs() < 1e-6, "colour ({r},{g},{b})");
|
||||
assert!((a - 1.0).abs() < 1e-6, "alpha {a}");
|
||||
}
|
||||
|
||||
/// Deep EXR is HDR: colour above 1.0 survives the composite (it used to be clamped), and a
|
||||
/// tiled file whose edge tiles are narrower than the tile size decodes like the scan lines.
|
||||
#[test]
|
||||
fn hdr_colour_is_kept_and_edge_tiles_match_scanlines() {
|
||||
let (w, h) = (7, 5);
|
||||
let counts: Vec<u32> = (0..w * h).map(|p| (p as u32 * 3) % 4).collect();
|
||||
let sample =
|
||||
|p: usize, ci: usize| -> Vec<f32> { (0..(p as u32 * 3) % 4).map(|k| [1.0, 0.25 * k as f32, 0.5, 2.0 + p as f32 / 8.0, 10.0 - k as f32][ci]).collect() };
|
||||
for compression in [0u8, 1, 2] {
|
||||
let scan = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, None, &counts, &sample));
|
||||
let tiled = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, Some((3, 2)), &counts, &sample));
|
||||
assert_eq!(scan, tiled, "compression {compression}");
|
||||
// Pixel 1 has 3 opaque samples; the front one (k = 2, Z = 8) has R = 2.125.
|
||||
assert!((scan[4] - 2.125).abs() < 1e-5, "compression {compression}: R {}", scan[4]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels without samples are transparent black, and the structured counts say so.
|
||||
#[test]
|
||||
fn empty_pixels_are_transparent() {
|
||||
let mut counts = vec![0u32; 16];
|
||||
counts[5] = 1;
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &|p, ci| {
|
||||
if p == 5 { vec![[1.0f32, 0.5, 0.5, 0.5, 1.0][ci]] } else { Vec::new() }
|
||||
});
|
||||
let px = rgba_flat(&bytes);
|
||||
for p in 0..16 {
|
||||
let base = p * 4;
|
||||
if p == 5 {
|
||||
assert!((px[base + 3] - 1.0).abs() < 1e-6);
|
||||
} else {
|
||||
assert!(px[base..base + 4].iter().all(|v| *v == 0.0), "pixel {p} not empty");
|
||||
}
|
||||
}
|
||||
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
|
||||
assert_eq!(deep.total_samples(), 1);
|
||||
assert_eq!(deep.sample_range(5).end, 1);
|
||||
}
|
||||
|
||||
/// RLE, ZIPS and NONE decode to identical pixels and identical structured samples.
|
||||
#[test]
|
||||
fn rle_and_zips_match_uncompressed() {
|
||||
let counts: Vec<u32> = (0..16).map(|p| if p % 3 == 0 { 2 } else { 1 }).collect();
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> {
|
||||
if counts[p] == 2 {
|
||||
match ci {
|
||||
0 => vec![0.75, 0.25],
|
||||
1 => vec![0.1, 0.2],
|
||||
2 => vec![0.3, 0.4],
|
||||
3 => vec![0.5, 0.6],
|
||||
_ => vec![7.0, 3.0],
|
||||
}
|
||||
} else {
|
||||
sample_rgba(p, ci)
|
||||
}
|
||||
};
|
||||
let base = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
|
||||
let px = rgba_flat(&base);
|
||||
let deep_base = decode_deep_exr(&base, &Limits::none()).expect("deep");
|
||||
for kind in [1u8, 2] {
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, kind, None, &counts, &sample);
|
||||
let other = rgba_flat(&bytes);
|
||||
for (i, (a, b)) in px.iter().zip(other.iter()).enumerate() {
|
||||
assert!((a - b).abs() < 1e-6, "compression {kind}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
|
||||
assert_eq!(deep.counts, deep_base.counts);
|
||||
for (a, b) in deep.channels.iter().zip(deep_base.channels.iter()) {
|
||||
assert_eq!(a.name, b.name);
|
||||
assert_eq!(a.samples, b.samples, "channel {}", a.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep tiles decode to the same pixels as deep scanlines of the same data.
|
||||
#[test]
|
||||
fn tiles_match_scanlines() {
|
||||
let counts = vec![1u32, 0, 2, 1, 1, 1, 0, 1, 2, 1, 1, 0, 1, 1, 1, 1];
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> {
|
||||
if counts[p] == 2 {
|
||||
match ci {
|
||||
0 => vec![0.5, 0.5],
|
||||
1 => vec![0.2, 0.1],
|
||||
2 => vec![0.3, 0.3],
|
||||
3 => vec![0.4, 0.4],
|
||||
_ => vec![2.0, 8.0],
|
||||
}
|
||||
} else if counts[p] > 0 {
|
||||
vec![[0.5, 0.2, 0.4, 0.7, 4.0][ci]]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let sl = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
|
||||
let tiled = gen_deep(4, 4, &RGBAZ, 0, Some((2, 2)), &counts, &sample);
|
||||
let a = rgba_flat(&sl);
|
||||
let b = rgba_flat(&tiled);
|
||||
for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
|
||||
assert!((x - y).abs() < 1e-6, "tile sample {i}: {x} vs {y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-float channels come back as F16 samples with exact values.
|
||||
#[test]
|
||||
fn half_channels_round_trip() {
|
||||
let channels = [Chan { name: "A", ty: 1 }, Chan { name: "B", ty: 1 }, Chan { name: "G", ty: 1 }, Chan { name: "R", ty: 1 }, Chan { name: "Z", ty: 1 }];
|
||||
let bytes = gen_deep(4, 4, &channels, 2, None, COUNTS_ONE, &|p, _ci| {
|
||||
let v = f16::from_f32(0.5).to_f32();
|
||||
vec![v * (p as f32 + 1.0) / 4.0]
|
||||
});
|
||||
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
|
||||
for c in &deep.channels {
|
||||
assert_eq!(c.sample, photocraft_codecs::SampleType::F16, "{}", c.name);
|
||||
}
|
||||
let a = deep.channel("A").expect("A");
|
||||
assert_eq!(a.samples.len(), 16);
|
||||
}
|
||||
|
||||
/// Without an alpha channel the front-most sample wins, opaque; luminance maps to gray.
|
||||
#[test]
|
||||
fn without_alpha_front_sample_is_opaque() {
|
||||
let channels = [Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }];
|
||||
let counts = vec![2u32; 16];
|
||||
let bytes = gen_deep(4, 4, &channels, 0, None, &counts, &|p, ci| {
|
||||
if p % 2 == 0 {
|
||||
match ci {
|
||||
0 => vec![0.9, 0.1],
|
||||
1 => vec![0.2, 0.8],
|
||||
2 => vec![0.4, 0.6],
|
||||
_ => vec![2.0, 6.0],
|
||||
}
|
||||
} else {
|
||||
vec![[0.0, 0.0, 1.0, 1.0][ci], [0.0, 0.0, 1.0, 9.0][ci]]
|
||||
}
|
||||
});
|
||||
let img = decode(&bytes).expect("decodes");
|
||||
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgb);
|
||||
let px = img.to_f32_samples().expect("f32");
|
||||
// Even pixels: the front sample (z=2) is (r 0.4, g 0.2, b 0.9).
|
||||
assert!((px[0] - 0.4).abs() < 1e-6 && (px[1] - 0.2).abs() < 1e-6 && (px[2] - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// decode_deep returns the counts and channels in file order.
|
||||
#[test]
|
||||
fn decode_deep_structure() {
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
|
||||
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
|
||||
assert_eq!((deep.width, deep.height), (4, 4));
|
||||
assert_eq!(deep.channels.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B", "G", "R", "Z"]);
|
||||
assert_eq!(deep.counts.len(), 17);
|
||||
assert_eq!(deep.counts[0], 0);
|
||||
assert_eq!(deep.total_samples(), 16);
|
||||
for p in 0..16 {
|
||||
assert_eq!(deep.sample_range(p).end - deep.sample_range(p).start, 1);
|
||||
}
|
||||
assert_eq!(deep.channel("Z").expect("Z").samples[5], 15.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Hostile input: every damaged file errors, nothing panics.
|
||||
|
||||
fn truncate_sweep(bytes: &[u8]) {
|
||||
// Cut at every offset (cap the count for speed) — a cut file must error, not panic.
|
||||
let step = (bytes.len() / 96).max(1);
|
||||
let mut i = step;
|
||||
while i < bytes.len() {
|
||||
let cut = &bytes[..i];
|
||||
let _ = decode_deep_exr(cut, &Limits::none());
|
||||
let _ = decode(cut);
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_files_error_never_panic() {
|
||||
for kind in [0u8, 1, 2] {
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, kind, None, COUNTS_ONE, &sample_rgba);
|
||||
truncate_sweep(&bytes);
|
||||
}
|
||||
let tiled = gen_deep(4, 4, &RGBAZ, 1, Some((2, 2)), COUNTS_ONE, &sample_rgba);
|
||||
truncate_sweep(&tiled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_offsets_error() {
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
|
||||
// A tiny sample-data budget rejects the file.
|
||||
let tight = Limits { max_alloc: 64, ..Limits::none() };
|
||||
assert!(matches!(decode_deep_exr(&bytes, &tight), Err(CodecError::LimitExceeded(_))));
|
||||
// A non-deep file is unsupported for decode_deep.
|
||||
let flat = photocraft_codecs::encode(
|
||||
&photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgba, [128; 16].to_vec()).expect("img"),
|
||||
photocraft_codecs::Format::OpenExr,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("encode");
|
||||
assert!(matches!(decode_deep_exr(&flat, &Limits::none()), Err(CodecError::Unsupported { .. })));
|
||||
// The last chunk's y coordinate destroyed: out of the data window.
|
||||
let mut broken = bytes.clone();
|
||||
let header_end = header_end_of(&broken);
|
||||
let last_off = u64::from_le_bytes(broken[header_end + 24..header_end + 32].try_into().expect("8")) as usize;
|
||||
broken[last_off..last_off + 4].copy_from_slice(&i32::MAX.to_le_bytes());
|
||||
assert!(decode_deep_exr(&broken, &Limits::none()).is_err());
|
||||
assert!(decode(&broken).is_err());
|
||||
// Two chunks claiming the same block: duplicate offsets.
|
||||
let mut dup = bytes.clone();
|
||||
let header_end = header_end_of(&dup);
|
||||
let first: [u8; 8] = dup[header_end..header_end + 8].try_into().expect("8");
|
||||
dup[header_end + 8..header_end + 16].copy_from_slice(&first);
|
||||
assert!(matches!(decode_deep_exr(&dup, &Limits::none()), Err(CodecError::Malformed { .. })));
|
||||
// An offset pointing past the file.
|
||||
let mut far = bytes.clone();
|
||||
far[header_end..header_end + 8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
|
||||
}
|
||||
|
||||
/// Walk the attributes like the format prescribes: the offset table starts after the
|
||||
/// header's terminating zero byte.
|
||||
fn header_end_of(bytes: &[u8]) -> usize {
|
||||
let mut p = 8usize; // magic + version
|
||||
loop {
|
||||
let Some(nul) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute name") };
|
||||
if nul == 0 {
|
||||
return p + 1; // the empty name terminates the header
|
||||
}
|
||||
p += nul + 1;
|
||||
let Some(t) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute type") };
|
||||
p += t + 1;
|
||||
let size = i32::from_le_bytes(bytes[p..p + 4].try_into().expect("size")) as usize;
|
||||
p += 4 + size;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_count_table_errors() {
|
||||
// The declared unpacked size disagrees with the count table.
|
||||
let mut bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
|
||||
// Find the first chunk: the offset table follows the header (find the "version" attr end).
|
||||
let header_end = header_end_of(&bytes);
|
||||
let first_chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("8")) as usize;
|
||||
// Scanline chunk: y (4), three u64s (24); bump the unpacked size by one.
|
||||
let at = first_chunk + 4 + 16;
|
||||
let v = u64::from_le_bytes(bytes[at..at + 8].try_into().expect("8"));
|
||||
bytes[at..at + 8].copy_from_slice(&(v + 1).to_le_bytes());
|
||||
assert!(matches!(decode_deep_exr(&bytes, &Limits::none()), Err(CodecError::Malformed { .. })));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Real reference files (corpus/exr, copied in by hand from the OpenEXR repository's test
|
||||
// images; not pinned by xtask). Skipped with a message when the folder is absent.
|
||||
|
||||
#[test]
|
||||
fn real_deep_files_decode() {
|
||||
let dir = std::path::Path::new("../../corpus/exr");
|
||||
let entries: Vec<_> =
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "exr")).collect();
|
||||
if entries.is_empty() {
|
||||
eprintln!("skipped: no files in corpus/exr (copy the *.deep.exr test images there)");
|
||||
return;
|
||||
}
|
||||
for path in entries {
|
||||
let bytes = std::fs::read(&path).expect("read");
|
||||
let deep = decode_deep_exr(&bytes, &Limits::none()).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
let img = decode(&bytes).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
assert_eq!((deep.width, deep.height), (img.width(), img.height()));
|
||||
assert!(deep.total_samples() > 0, "{}: no samples", path.display());
|
||||
let flat = img.to_f32_samples().expect("f32");
|
||||
assert_eq!(flat.len(), deep.width as usize * deep.height as usize * 4);
|
||||
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,29 @@ fn avif_never_readable() {
|
||||
assert!(matches!(decode_as(Format::Avif, b"\0\0\0\x1cftypavif"), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heif_is_detected_always_and_readable_only_with_the_feature() {
|
||||
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
assert_eq!(detect(header), Some(Format::Heif));
|
||||
assert_eq!(from_extension("IMG_0001.HEIC"), Some(Format::Heif));
|
||||
assert_eq!(caps(Format::Heif).read, cfg!(feature = "heif"));
|
||||
assert!(!caps(Format::Heif).write);
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Heif));
|
||||
// Either way a bare header is an error, never a panic.
|
||||
assert!(decode_as(Format::Heif, header).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_heif_brands() {
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some(Format::Heif));
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heic"), Some(Format::Heif), "generic major brand");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypheix\0\0\0\0mif1heix"), Some(Format::Heif), "10-bit");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc"), Some(Format::Heif), "image sequence");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "AVIF wins over the generic brand");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not HEIF");
|
||||
assert_eq!(detect(b"\0\0\0\x14ftypqt \0\0\0\0qt "), None, "QuickTime is not HEIF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_are_internally_consistent() {
|
||||
for f in Format::ALL {
|
||||
|
||||
@@ -86,7 +86,8 @@ fn icc_warning_suppressed_when_not_embedding() {
|
||||
#[test]
|
||||
fn exif_xmp_dpi_text_dropped_for_qoi() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] };
|
||||
img.meta =
|
||||
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
|
||||
let w = warns(&img, Format::Qoi);
|
||||
for x in [W::ExifDropped, W::XmpDropped, W::DpiDropped, W::TextDropped] {
|
||||
assert!(w.contains(&x), "{x:?}");
|
||||
@@ -95,7 +96,8 @@ fn exif_xmp_dpi_text_dropped_for_qoi() {
|
||||
#[test]
|
||||
fn webp_drops_dpi_and_text_only() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] };
|
||||
img.meta =
|
||||
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
|
||||
let w = warns(&img, Format::WebP);
|
||||
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! HEIF/HEIC decoding against Apple's own encoder and decoder (features `corpus` and `heif`;
|
||||
//! run with `cargo xtask test-corpus`). The fixtures are fetched into `corpus/heif/` by
|
||||
//! `cargo xtask corpus --heif` (pins: `xtask/src/corpus_pins.rs`); a missing corpus fails.
|
||||
//! Most come from heic-rs (`heic-rs/`, MIT OR Apache-2.0): encoded by macOS `sips` from synthetic
|
||||
//! PNGs, each `.ref.png` being Apple's decode of the `.heic` next to it, the ground truth we
|
||||
//! compare with. The 10-bit one is pillow-heif's (`pillow-heif/`, BSD-3-Clause), with the source
|
||||
//! it was encoded from.
|
||||
|
||||
#![cfg(all(feature = "corpus", feature = "heif"))]
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// A file of `corpus/heif/`.
|
||||
fn corpus(rel: &str) -> Vec<u8> {
|
||||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif").join(rel);
|
||||
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()))
|
||||
}
|
||||
|
||||
static FILES: LazyLock<[Vec<u8>; 4]> =
|
||||
LazyLock::new(|| ["heic-rs/checker-64.heic", "heic-rs/rgb-strips-96.heic", "heic-rs/checker-1024.heic", "heic-rs/with-exif.heic"].map(corpus));
|
||||
|
||||
fn checker_64() -> &'static [u8] {
|
||||
&FILES[0]
|
||||
}
|
||||
fn strips_96() -> &'static [u8] {
|
||||
&FILES[1]
|
||||
}
|
||||
/// iPhone-style grid of 512x512 tiles (2x2).
|
||||
fn checker_grid() -> &'static [u8] {
|
||||
&FILES[2]
|
||||
}
|
||||
fn with_exif() -> &'static [u8] {
|
||||
&FILES[3]
|
||||
}
|
||||
|
||||
fn reference(png: &[u8]) -> Image {
|
||||
decode_as(Format::Png, png).unwrap().convert(ChannelLayout::Rgb, SampleType::U8)
|
||||
}
|
||||
|
||||
/// `bytes` with the first `irot` box set to `angle` × 90° anticlockwise (every fixture has one, at 0).
|
||||
fn with_irot(bytes: &[u8], angle: u8) -> Vec<u8> {
|
||||
let at = bytes.windows(4).position(|w| w == b"irot").unwrap() + 4;
|
||||
let mut b = bytes.to_vec();
|
||||
b[at] = angle;
|
||||
b
|
||||
}
|
||||
|
||||
/// `bytes` with the big-endian EXIF Orientation entry (SHORT, count 1) set to `o`.
|
||||
fn with_exif_orientation(bytes: &[u8], o: u8) -> Vec<u8> {
|
||||
let at = bytes.windows(8).position(|w| w == [0x01, 0x12, 0, 3, 0, 0, 0, 1]).unwrap() + 9;
|
||||
let mut b = bytes.to_vec();
|
||||
b[at] = o;
|
||||
b
|
||||
}
|
||||
|
||||
fn keep() -> DecodeOptions {
|
||||
DecodeOptions { keep_orientation: true, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heif_is_read_only_and_listed() {
|
||||
let c = caps(Format::Heif);
|
||||
assert!(c.read && !c.write);
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Heif));
|
||||
for ext in ["heic", "HEIC", "photo.heif", "IMG_0001.HIF"] {
|
||||
assert_eq!(from_extension(ext), Some(Format::Heif), "{ext}");
|
||||
}
|
||||
let img = decode(checker_64()).unwrap();
|
||||
assert!(matches!(encode(&img, Format::Heif, &EncodeOptions::default()), Err(CodecError::Unsupported { .. })));
|
||||
assert_eq!(fidelity_warnings(&img, Format::Heif), vec![FidelityWarning::WriteUnsupported { format: Format::Heif }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_heif_brands() {
|
||||
for f in [checker_64(), strips_96(), checker_grid(), with_exif()] {
|
||||
assert_eq!(detect(f), Some(Format::Heif));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_picture_matches_apple_decode() {
|
||||
for (heic, png) in [(checker_64(), "heic-rs/checker-64.ref.png"), (strips_96(), "heic-rs/rgb-strips-96.ref.png")] {
|
||||
let img = decode(heic).unwrap();
|
||||
let want = reference(&corpus(png));
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U8));
|
||||
assert_eq!(img.dimensions(), want.dimensions());
|
||||
assert!(max_abs_diff(&img, &want) <= 3.0 / 255.0, "max diff {}", max_abs_diff(&img, &want) * 255.0);
|
||||
assert!(psnr(&img, &want) > 45.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_tiled_photo_matches_apple_decode() {
|
||||
// iPhone photos are grids of 512x512 HEVC tiles: here 2x2.
|
||||
let img = decode(checker_grid()).unwrap();
|
||||
let want = reference(&corpus("heic-rs/checker-1024.ref.png"));
|
||||
assert_eq!(img.dimensions(), (1024, 1024));
|
||||
assert!(max_abs_diff(&img, &want) <= 3.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_strips_keep_their_colours() {
|
||||
// Three vertical strips, pure red, green and blue: catches swapped channels or matrices.
|
||||
let img = decode(strips_96()).unwrap();
|
||||
for (x, c) in [(16, 0), (48, 1), (80, 2)] {
|
||||
for ch in 0..3 {
|
||||
let v = img.get(x, 16, ch);
|
||||
assert!(if ch == c { v > 0.9 } else { v < 0.1 }, "strip at x={x} channel {ch} = {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_rotation_is_applied_once() {
|
||||
// irot 1 = 90° anticlockwise: the red strip (left) ends at the bottom, blue (right) on top.
|
||||
let rotated = with_irot(strips_96(), 1);
|
||||
let img = decode(&rotated).unwrap();
|
||||
assert_eq!(img.dimensions(), (32, 96));
|
||||
assert!(img.get(16, 8, 2) > 0.9 && img.get(16, 8, 0) < 0.1, "blue on top");
|
||||
assert!(img.get(16, 88, 0) > 0.9 && img.get(16, 88, 2) < 0.1, "red at the bottom");
|
||||
// keep_orientation hands back the pixels as coded.
|
||||
assert_eq!(decode_with(&rotated, &keep()).unwrap().dimensions(), (96, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_and_xmp_survive_and_exif_orientation_is_not_applied_twice() {
|
||||
let img = decode(with_exif()).unwrap();
|
||||
assert_eq!(img.dimensions(), (2048, 1536));
|
||||
let exif = img.meta.exif.as_deref().unwrap();
|
||||
assert!(exif.starts_with(b"MM\0*"), "TIFF-structured EXIF, without the HEIF offset header");
|
||||
assert!(img.meta.xmp.as_deref().unwrap().contains("x:xmpmeta"));
|
||||
|
||||
// HEIF's EXIF Orientation repeats what irot says; the container is the authority.
|
||||
let tagged = with_exif_orientation(with_exif(), 6);
|
||||
let img = decode(&tagged).unwrap();
|
||||
assert_eq!(img.dimensions(), (2048, 1536), "not turned by the EXIF tag");
|
||||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1, "rewritten so exports don't turn it either");
|
||||
let raw = decode_with(&tagged, &keep()).unwrap();
|
||||
assert_eq!(exif_orientation(raw.meta.exif.as_deref().unwrap()), 6, "kept verbatim when asked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_checked_before_decoding() {
|
||||
let tight = |limits| DecodeOptions { limits, ..Default::default() };
|
||||
let small = Limits { max_width: 512, ..Default::default() };
|
||||
assert!(matches!(decode_with(checker_grid(), &tight(small)), Err(CodecError::LimitExceeded(_))));
|
||||
let few = Limits { max_pixels: 1000, ..Default::default() };
|
||||
assert!(matches!(decode_with(checker_64(), &tight(few)), Err(CodecError::LimitExceeded(_))));
|
||||
let bytes = Limits { max_alloc: 64 * 64 * 3 - 1, ..Default::default() };
|
||||
assert!(matches!(decode_with(checker_64(), &tight(bytes)), Err(CodecError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_broken_files_are_clear_errors() {
|
||||
// An ftyp and nothing else: a HEIF image sequence would have a moov here instead of a meta.
|
||||
let r = decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
|
||||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
|
||||
assert!(decode_as(Format::Heif, b"").is_err());
|
||||
assert!(decode_as(Format::Heif, &checker_grid()[..checker_grid().len() / 2]).is_err(), "half a file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_errors_or_decodes_never_panics() {
|
||||
for bytes in [checker_64(), strips_96(), checker_grid()] {
|
||||
for cut in 0..bytes.len() {
|
||||
let _ = decode_as(Format::Heif, &bytes[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_with_alpha_decodes_to_16_bit_rgba() {
|
||||
// pillow-heif's fixture: a 16-bit RGBA gradient (the .src.png) encoded as 10-bit HEIF by libheif/x265.
|
||||
let img = decode(&corpus("pillow-heif/heif/RGBA_10__29x100.heif")).unwrap();
|
||||
let src = decode_as(Format::Png, &corpus("pillow-heif/non_heif/RGBA_16__29x100.png")).unwrap();
|
||||
assert_eq!((img.dimensions(), img.layout(), img.sample_type()), ((29, 100), ChannelLayout::Rgba, SampleType::U16));
|
||||
// Depth is kept, not 8 bits stretched: most samples are not multiples of 257.
|
||||
let samples = img.to_u16_samples().unwrap();
|
||||
assert!(samples.iter().filter(|v| *v % 257 != 0).count() > samples.len() / 2);
|
||||
// The alpha plane is coded on its own and comes back almost exactly.
|
||||
for y in 0..100 {
|
||||
for x in 0..29 {
|
||||
assert!((img.get(x, y, 3) - src.get(x, y, 3)).abs() < 0.002, "alpha at {x},{y}");
|
||||
}
|
||||
}
|
||||
// Colour went through 4:2:0 chroma on a 29-pixel-wide saturated gradient, which costs about
|
||||
// 26 dB at any depth (the 8-bit encode of the same source scores the same, as does a second
|
||||
// decoder); a decoder bug (range, matrix, bit shift) lands far below.
|
||||
let rgb = |i: &Image| i.convert(ChannelLayout::Rgb, SampleType::U16);
|
||||
assert!(psnr(&rgb(&img), &rgb(&src)) > 24.0, "{}", psnr(&rgb(&img), &rgb(&src)));
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
fn opts(l: Limits) -> DecodeOptions {
|
||||
DecodeOptions { limits: l }
|
||||
DecodeOptions { limits: l, ..Default::default() }
|
||||
}
|
||||
|
||||
fn is_limit(r: Result<Image, CodecError>) -> bool {
|
||||
|
||||
@@ -6,7 +6,7 @@ use photocraft_codecs::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn tight() -> DecodeOptions {
|
||||
DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 } }
|
||||
DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 }, ..Default::default() }
|
||||
}
|
||||
|
||||
fn samples() -> Vec<(Format, Vec<u8>)> {
|
||||
@@ -20,6 +20,14 @@ fn samples() -> Vec<(Format, Vec<u8>)> {
|
||||
v.push((f, encode(&img, f, &EncodeOptions::default()).unwrap()));
|
||||
}
|
||||
}
|
||||
// Read-only formats: Apple-encoded HEIC from `corpus/heif` (`cargo xtask corpus --heif`), a
|
||||
// single picture and a grid of tiles.
|
||||
#[cfg(all(feature = "corpus", feature = "heif"))]
|
||||
for name in ["rgb-strips-96.heic", "checker-1024.heic"] {
|
||||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif/heic-rs").join(name);
|
||||
let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()));
|
||||
v.push((Format::Heif, bytes));
|
||||
}
|
||||
// Extra variants: interlaced PNG, ASCII PNM, CMYK JPEG.
|
||||
let img = synth(19, 13, ChannelLayout::Rgb, SampleType::U8, 1, 0.3);
|
||||
v.push((Format::Png, encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap()));
|
||||
@@ -56,6 +64,13 @@ fn truncated_files_mostly_error() {
|
||||
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
|
||||
assert!(r.is_err(), "{f:?} decoded half a file");
|
||||
}
|
||||
// JPEG decodes leniently (the missing part grey) but never silently (#518).
|
||||
if f == Format::Jpeg {
|
||||
match decode_as_with(f, &bytes[..bytes.len() / 2], &tight()) {
|
||||
Ok(img) => assert_eq!(img.warnings, [DecodeWarning::Truncated { format: f }]),
|
||||
Err(e) => assert!(e.to_string().contains("before any image data"), "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +186,11 @@ proptest! {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_with_magic_never_panic(idx in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let magics: [&[u8]; 13] = [
|
||||
fn random_bytes_with_magic_never_panic(idx in 0usize..14, data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let magics: [&[u8]; 14] = [
|
||||
b"\x89PNG\r\n\x1a\n", &[0xFF, 0xD8, 0xFF], b"II*\0", b"RIFF\0\0\0\0WEBP", b"GIF89a", b"BM",
|
||||
&[0, 0, 1, 0, 1, 0], b"P6\n", b"qoif", &[0x76, 0x2F, 0x31, 0x01], b"#?RADIANCE\n", b"P7\n", b"PF\n",
|
||||
b"\0\0\0\x18ftypheic\0\0\0\0mif1heic",
|
||||
];
|
||||
let mut b = magics[idx].to_vec();
|
||||
b.extend_from_slice(&data);
|
||||
@@ -182,7 +198,7 @@ proptest! {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_each_format_never_panic(idx in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
||||
fn random_bytes_each_format_never_panic(idx in 0usize..Format::ALL.len(), data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
||||
let _ = decode_as_with(Format::ALL[idx], &data, &tight());
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ fn rich_image(layout: ChannelLayout) -> Image {
|
||||
xmp: Some(SAMPLE_XMP.to_owned()),
|
||||
dpi: Some((300.0, 150.0)),
|
||||
text: vec![("Description".into(), "synthetic test image".into()), ("Software".into(), "photocraft".into())],
|
||||
..Default::default()
|
||||
};
|
||||
img
|
||||
}
|
||||
@@ -155,6 +156,45 @@ fn jpeg_multi_segment_icc() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layered document's XMP can list every document ever placed in it and the text of every type
|
||||
/// layer: far more than the 64 KB one JPEG segment holds. The JPEG is still written, with that
|
||||
/// bookkeeping left out of the XMP; metadata too large even then is dropped with a warning.
|
||||
#[test]
|
||||
fn jpeg_with_oversized_metadata_still_encodes() {
|
||||
let ancestors: String = (0..3000).map(|i| format!("<rdf:li>xmp.did:{i:032x}</rdf:li>")).collect();
|
||||
let texts: String = (0..400)
|
||||
.map(|i| format!("<rdf:li rdf:parseType=\"Resource\"><ps:LayerName>Layer {i}</ps:LayerName><ps:LayerText>Words on layer {i}</ps:LayerText></rdf:li>"))
|
||||
.collect();
|
||||
let xmp = format!(
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:ps=\"http://example.com/ps/1.0/\" xmp:CreatorTool=\"synthetic\"><ps:DocumentAncestors>\n<rdf:Bag>{ancestors}</rdf:Bag></ps:DocumentAncestors><ps:TextLayers><rdf:Bag>{texts}</rdf:Bag></ps:TextLayers><ps:DocumentAncestorsNote/></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>"
|
||||
);
|
||||
assert!(xmp.len() > 120_000, "{}", xmp.len());
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut img = test_image(ChannelLayout::Rgb, sample);
|
||||
img.meta.xmp = Some(xmp.clone());
|
||||
let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&bytes).unwrap().meta.xmp.unwrap();
|
||||
assert!(back.contains("xmp:CreatorTool=\"synthetic\"") && back.ends_with("<?xpacket end=\"w\"?>"), "{sample:?}");
|
||||
assert!(!back.contains("DocumentAncestors>") && !back.contains("TextLayers"), "{sample:?}");
|
||||
assert!(back.contains("<ps:DocumentAncestorsNote/>"), "{sample:?}: a longer name is not the element");
|
||||
assert!(!fidelity_warnings(&img, Format::Jpeg).iter().any(|w| matches!(w, FidelityWarning::MetadataTooLarge { .. })));
|
||||
// Formats without a segment limit keep the packet whole.
|
||||
assert_eq!(decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap().meta.xmp.as_deref(), Some(xmp.as_str()));
|
||||
}
|
||||
// Still too large: the JPEG is written without it.
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
let big = format!("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><!-- {} --></x:xmpmeta>", "x".repeat(70_000));
|
||||
let mut exif = sample_exif();
|
||||
exif.resize(70_000, 0);
|
||||
img.meta.xmp = Some(big.clone());
|
||||
img.meta.exif = Some(exif);
|
||||
let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert!(back.meta.xmp.is_none() && back.meta.exif.is_none());
|
||||
let w = fidelity_warnings(&img, Format::Jpeg);
|
||||
assert!(w.contains(&FidelityWarning::MetadataTooLarge { what: "XMP", bytes: big.len() }), "{w:?}");
|
||||
assert!(w.contains(&FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: 70_000 }), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_flags_disable_metadata() {
|
||||
let img = rich_image(ChannelLayout::Rgb);
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
//! EXIF / TIFF orientation (#285): files are opened upright, and every export
|
||||
//! writes Orientation = 1 so the upright pixels are never rotated twice.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
use proptest::prelude::{any, prop_assert, prop_assert_eq, proptest};
|
||||
|
||||
/// A minimal EXIF (TIFF-structured) block: a filler tag, then Orientation.
|
||||
fn exif(o: u32, big: bool, long: bool) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
|
||||
v.extend_from_slice(&u32b(8));
|
||||
v.extend_from_slice(&u16b(2));
|
||||
// ImageDescription-ish filler (ASCII, 4 bytes inline).
|
||||
v.extend_from_slice(&u16b(0x010E));
|
||||
v.extend_from_slice(&u16b(2));
|
||||
v.extend_from_slice(&u32b(4));
|
||||
v.extend_from_slice(b"abc\0");
|
||||
v.extend_from_slice(&u16b(0x0112));
|
||||
if long {
|
||||
v.extend_from_slice(&u16b(4));
|
||||
v.extend_from_slice(&u32b(1));
|
||||
v.extend_from_slice(&u32b(o));
|
||||
} else {
|
||||
v.extend_from_slice(&u16b(3));
|
||||
v.extend_from_slice(&u32b(1));
|
||||
v.extend_from_slice(&u16b(o as u16));
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
v.extend_from_slice(&u32b(0));
|
||||
v
|
||||
}
|
||||
|
||||
/// The "upright" test picture: `w`×`h` gray values, distinct per pixel.
|
||||
fn upright(w: usize, h: usize, value: impl Fn(usize, usize) -> u8) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| value(x, y)).collect()
|
||||
}
|
||||
|
||||
/// What a camera stores for orientation `o` when the scene is `up` (`w`×`h`): the inverse
|
||||
/// of the turn a viewer applies. Returns the stored pixels and their size.
|
||||
fn stored(up: &[u8], w: usize, h: usize, o: u16) -> (Vec<u8>, usize, usize) {
|
||||
let (sw, sh) = if o >= 5 { (h, w) } else { (w, h) };
|
||||
let mut s = vec![0u8; up.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Where upright pixel (x, y) sits in the stored image.
|
||||
let (sx, sy) = match o {
|
||||
1 => (x, y),
|
||||
2 => (w - 1 - x, y), // mirrored horizontally
|
||||
3 => (w - 1 - x, h - 1 - y), // rotated 180°
|
||||
4 => (x, h - 1 - y), // mirrored vertically
|
||||
5 => (y, x), // transposed
|
||||
6 => (y, w - 1 - x), // viewer turns it 90° clockwise
|
||||
7 => (h - 1 - y, w - 1 - x), // transverse
|
||||
8 => (h - 1 - y, x), // viewer turns it 90° counter-clockwise
|
||||
_ => unreachable!(),
|
||||
};
|
||||
s[sy * sw + sx] = up[y * w + x];
|
||||
}
|
||||
}
|
||||
(s, sw, sh)
|
||||
}
|
||||
|
||||
/// Inserts a JPEG APP1 EXIF segment right after SOI.
|
||||
fn jpeg_with_exif(jpeg: &[u8], exif: &[u8]) -> Vec<u8> {
|
||||
let mut seg = b"Exif\0\0".to_vec();
|
||||
seg.extend_from_slice(exif);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Inserts a PNG eXIf chunk before the first IDAT.
|
||||
fn png_with_exif(png: &[u8], exif: &[u8]) -> Vec<u8> {
|
||||
let at = png.windows(4).position(|w| w == b"IDAT").unwrap() - 4;
|
||||
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
|
||||
let mut body = b"eXIf".to_vec();
|
||||
body.extend_from_slice(exif);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(&body);
|
||||
chunk.extend_from_slice(&body);
|
||||
chunk.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
let mut out = png[..at].to_vec();
|
||||
out.extend_from_slice(&chunk);
|
||||
out.extend_from_slice(&png[at..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn tiff_with_orientation(data: &[u8], w: u32, h: u32, o: u16) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype::Gray8};
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut cursor).unwrap();
|
||||
let mut im = enc.new_image::<Gray8>(w, h).unwrap();
|
||||
im.encoder().write_tag(tiff::tags::Tag::Orientation, o).unwrap();
|
||||
im.write_data(data).unwrap();
|
||||
cursor.into_inner()
|
||||
}
|
||||
|
||||
fn gray(w: usize, h: usize, px: Vec<u8>) -> Image {
|
||||
Image::from_u8(w as u32, h as u32, ChannelLayout::Gray, px).unwrap()
|
||||
}
|
||||
|
||||
/// Exact pixels: distinct values per pixel.
|
||||
fn exact_scene(w: usize, h: usize) -> Vec<u8> {
|
||||
upright(w, h, |x, y| (y * w + x) as u8 * 3 + 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_orientation_in_every_encoding() {
|
||||
for o in 1..=8 {
|
||||
for big in [false, true] {
|
||||
for long in [false, true] {
|
||||
let e = exif(o, big, long);
|
||||
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big} long={long}");
|
||||
let mut prefixed = b"Exif\0\0".to_vec();
|
||||
prefixed.extend_from_slice(&e);
|
||||
assert_eq!(exif_orientation(&prefixed), o as u16);
|
||||
let fixed = upright_exif(&prefixed);
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
assert_eq!(fixed.len(), prefixed.len(), "rewritten in place");
|
||||
assert_eq!(&fixed[..6], b"Exif\0\0");
|
||||
// Rewritten in the entry's own type and width: exactly `exif(1, ..)`.
|
||||
assert_eq!(&fixed[6..], &exif(1, big, long)[..], "o={o} big={big} long={long}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Already upright, no tag, or garbage: borrowed unchanged.
|
||||
assert!(matches!(upright_exif(&exif(1, false, false)), std::borrow::Cow::Borrowed(_)));
|
||||
assert!(matches!(upright_exif(&sample_exif()), std::borrow::Cow::Borrowed(_)));
|
||||
assert!(matches!(upright_exif(b"nonsense"), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_out_of_range_orientation_reads_as_1() {
|
||||
for bad in [0, 9, 255, 0xFFFF, 0x1_0006] {
|
||||
assert_eq!(exif_orientation(&exif(bad, false, true)), 1, "{bad}");
|
||||
}
|
||||
let good = exif(6, false, false);
|
||||
assert_eq!(exif_orientation(&good), 6);
|
||||
for n in 0..good.len() {
|
||||
// Any cut, even one that keeps the Orientation entry but loses the next-IFD pointer.
|
||||
assert_eq!(exif_orientation(&good[..n]), 1, "truncated at {n}");
|
||||
assert!(matches!(upright_exif(&good[..n]), std::borrow::Cow::Borrowed(_)), "truncated at {n}");
|
||||
}
|
||||
// IFD offset past the end, a huge entry count, a wrong field type.
|
||||
let mut far = good.clone();
|
||||
far[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(exif_orientation(&far), 1);
|
||||
let mut many = good.clone();
|
||||
many[8..10].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert_eq!(exif_orientation(&many), 1, "an entry count past the end is a truncated table");
|
||||
let mut typed = good.clone();
|
||||
typed[24..26].copy_from_slice(&2u16.to_le_bytes()); // ASCII
|
||||
assert_eq!(exif_orientation(&typed), 1);
|
||||
assert_eq!(exif_orientation(&[]), 1);
|
||||
assert_eq!(exif_orientation(b"II*\0"), 1);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn random_exif_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..256), le in any::<bool>()) {
|
||||
let mut b = if le { b"II*\0\x08\0\0\0".to_vec() } else { b"MM\0*\0\0\0\x08".to_vec() };
|
||||
b.extend_from_slice(&bytes);
|
||||
let o = exif_orientation(&b);
|
||||
prop_assert!((1..=8).contains(&o));
|
||||
prop_assert_eq!(exif_orientation(&upright_exif(&b)), 1);
|
||||
let _ = exif_orientation(&bytes);
|
||||
let _ = upright_exif(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_xmp_never_panics(s in ".{0,64}", tail in ".{0,16}") {
|
||||
let x = format!("{s}tiff:Orientation{tail}");
|
||||
let _ = upright_xmp(&x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmp_orientation_is_rewritten() {
|
||||
let attr = r#"<rdf:Description tiff:Orientation="6" tiff:Make="X"/>"#;
|
||||
assert_eq!(upright_xmp(attr), r#"<rdf:Description tiff:Orientation="1" tiff:Make="X"/>"#);
|
||||
let elem = "<tiff:Orientation>8</tiff:Orientation><tiff:Orientation>3</tiff:Orientation>";
|
||||
assert_eq!(upright_xmp(elem), "<tiff:Orientation>1</tiff:Orientation><tiff:Orientation>1</tiff:Orientation>");
|
||||
assert_eq!(upright_xmp("tiff:Orientation='12'"), "tiff:Orientation='1'");
|
||||
for same in [r#"tiff:Orientation="1""#, "no orientation", "tiff:Orientation", "tiff:Orientation=\"\"", "tiff:Orientation: 6"] {
|
||||
assert!(matches!(upright_xmp(same), std::borrow::Cow::Borrowed(_)), "{same}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (5, 3);
|
||||
let up = exact_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let png = encode(&gray(sw, sh, s.clone()), Format::Png, &EncodeOptions::default()).unwrap();
|
||||
let file = png_with_exif(&png, &exif(u32::from(o), o % 2 == 0, false));
|
||||
let img = decode(&file).unwrap();
|
||||
assert_eq!(img.dimensions(), (w as u32, h as u32), "o={o}");
|
||||
assert_eq!(img.data(), &up[..], "o={o}");
|
||||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1, "o={o}: metadata says upright");
|
||||
// Opting out keeps the stored pixels and the tag.
|
||||
let raw = decode_with(&file, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
assert_eq!((raw.dimensions(), raw.data()), ((sw as u32, sh as u32), &s[..]), "o={o}");
|
||||
assert_eq!(exif_orientation(raw.meta.exif.as_deref().unwrap()), o);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (4, 7);
|
||||
let up = exact_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let img = decode(&tiff_with_orientation(&s, sw as u32, sh as u32, o)).unwrap();
|
||||
assert_eq!(img.dimensions(), (w as u32, h as u32), "o={o}");
|
||||
assert_eq!(img.data(), &up[..], "o={o}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_opens_upright() {
|
||||
let (w, h) = (6, 2);
|
||||
let up = exact_scene(w, h);
|
||||
for o in [3, 6, 8] {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let mut out = Vec::new();
|
||||
let mut enc = image_webp::WebPEncoder::new(&mut out);
|
||||
enc.set_exif_metadata(exif(u32::from(o), false, false));
|
||||
enc.encode(&s, sw as u32, sh as u32, image_webp::ColorType::L8).unwrap();
|
||||
// WebP has no gray mode: it comes back as RGB.
|
||||
let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8);
|
||||
assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 8×8-block scene so JPEG keeps each block's value: block (bx, by) is distinct.
|
||||
fn block_scene(w: usize, h: usize) -> Vec<u8> {
|
||||
upright(w, h, |x, y| (20 + 25 * (x / 8 + (w / 8) * (y / 8))) as u8)
|
||||
}
|
||||
|
||||
fn assert_blocks(img: &Image, up: &[u8], w: usize, h: usize, what: &str) {
|
||||
assert_eq!(img.dimensions(), (w as u32, h as u32), "{what}");
|
||||
let d = img.data();
|
||||
for by in 0..h / 8 {
|
||||
for bx in 0..w / 8 {
|
||||
let i = (by * 8 + 4) * w + bx * 8 + 4;
|
||||
assert!((i32::from(d[i]) - i32::from(up[i])).abs() <= 4, "{what}: block ({bx},{by}) is {} not {}", d[i], up[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (32, 16);
|
||||
let up = block_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let jpeg = encode(&gray(sw, sh, s), Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let file = jpeg_with_exif(&jpeg, &exif(u32::from(o), o > 4, o == 7));
|
||||
let img = decode(&file).unwrap();
|
||||
assert_blocks(&img, &up, w, h, &format!("o={o}"));
|
||||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_jpeg_with_dpi_swaps_resolution_on_a_quarter_turn() {
|
||||
let img = Image::from_u8(16, 8, ChannelLayout::Rgb, vec![128; 16 * 8 * 3]).unwrap().with_meta(Metadata { dpi: Some((300.0, 150.0)), ..Default::default() });
|
||||
let jpeg = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&jpeg_with_exif(&jpeg, &exif(6, false, false))).unwrap();
|
||||
assert_eq!(back.dimensions(), (8, 16));
|
||||
assert_eq!(back.meta.dpi, Some((150.0, 300.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_orientation_1_and_keeps_pixels() {
|
||||
let (w, h) = (32, 16);
|
||||
let up = block_scene(w, h);
|
||||
let (s, sw, sh) = stored(&up, w, h, 6);
|
||||
let jpeg = encode(&gray(sw, sh, s), Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let opened = decode(&jpeg_with_exif(&jpeg, &exif(6, false, false))).unwrap();
|
||||
assert_blocks(&opened, &up, w, h, "opened");
|
||||
|
||||
// Even an image whose metadata still claims 6 (e.g. from a PSD) is written as 1.
|
||||
let mut stale = opened.clone();
|
||||
stale.meta.exif = Some(exif(6, true, false));
|
||||
stale.meta.xmp = Some(r#"<x tiff:Orientation="6"/>"#.into());
|
||||
for format in [Format::Png, Format::Jpeg, Format::WebP, Format::Tiff] {
|
||||
let bytes = encode(&stale, format, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let raw = decode_with(&bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
if let Some(e) = &raw.meta.exif {
|
||||
assert_eq!(exif_orientation(e), 1, "{format:?}");
|
||||
}
|
||||
if let Some(x) = &raw.meta.xmp {
|
||||
assert!(x.contains(r#"tiff:Orientation="1""#), "{format:?}: {x}");
|
||||
}
|
||||
assert_eq!(exif_orientation(&bytes), 1, "{format:?} file");
|
||||
// Pixels as shown, both with and without applying orientation on reopen.
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.dimensions(), raw.dimensions(), "{format:?}");
|
||||
if format == Format::Jpeg {
|
||||
assert_blocks(&back, &up, w, h, "jpeg reopen");
|
||||
} else {
|
||||
assert_eq!(back.convert(ChannelLayout::Gray, SampleType::U8).data(), opened.data(), "{format:?} lossless");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_open_save_reopen_is_stable() {
|
||||
let (w, h) = (24, 16);
|
||||
let up = block_scene(w, h);
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let jpeg = encode(&gray(sw, sh, s), Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let first = decode(&jpeg_with_exif(&jpeg, &exif(u32::from(o), false, false))).unwrap();
|
||||
let saved = encode(&first, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, ..Default::default() }).unwrap();
|
||||
let second = decode(&saved).unwrap();
|
||||
assert_blocks(&second, &up, w, h, &format!("reopened o={o}"));
|
||||
assert_eq!(exif_orientation(second.meta.exif.as_deref().unwrap()), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_exif_in_a_jpeg_opens_as_stored() {
|
||||
let img = gray(16, 8, vec![90; 128]);
|
||||
let jpeg = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let mut rng = Rng::new(285);
|
||||
let mut cases = vec![Vec::new(), b"II*\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), exif(6, false, false)[..20].to_vec()];
|
||||
cases.extend((0..64).map(|i| {
|
||||
let mut b = if i % 2 == 0 { b"II*\0\x08\0\0\0".to_vec() } else { b"MM\0*\0\0\0\x08".to_vec() };
|
||||
b.extend(rng.bytes(i * 7));
|
||||
b
|
||||
}));
|
||||
for e in cases {
|
||||
let back = decode(&jpeg_with_exif(&jpeg, &e)).unwrap();
|
||||
let o = exif_orientation(&e);
|
||||
let want = if o >= 5 { (8, 16) } else { (16, 8) };
|
||||
assert_eq!(back.dimensions(), want, "{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_layout_and_depth_orients_and_inverts() {
|
||||
let inverse = |o: u16| match o {
|
||||
6 => 8,
|
||||
8 => 6,
|
||||
o => o,
|
||||
};
|
||||
for layout in ChannelLayout::ALL {
|
||||
for sample in SampleType::ALL {
|
||||
let img = test_image(layout, sample);
|
||||
for o in 0..=9u16 {
|
||||
let turned = img.clone().oriented(o).unwrap();
|
||||
if (2..=8).contains(&o) && o >= 5 {
|
||||
assert_eq!(turned.dimensions(), (img.height(), img.width()));
|
||||
} else {
|
||||
assert_eq!(turned.dimensions(), img.dimensions());
|
||||
}
|
||||
let back = turned.oriented(inverse(o)).unwrap();
|
||||
assert_eq!(back.data(), img.data(), "{layout:?} {sample:?} o={o}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #285 performance budget: turning a 24 MP photo upright on open adds ≤ 50 ms
|
||||
/// (release build: `cargo test --release -p photocraft-codecs --test orientation -- --ignored`).
|
||||
#[test]
|
||||
#[ignore = "timing; run in release"]
|
||||
fn rotating_24_mp_is_fast() {
|
||||
let (w, h) = (6000u32, 4000u32);
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgb, (0..w as usize * h as usize * 3).map(|i| i as u8).collect()).unwrap();
|
||||
for o in [6u16, 3, 8] {
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..5 {
|
||||
let c = img.clone();
|
||||
let t = std::time::Instant::now();
|
||||
let r = c.oriented(o).unwrap();
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(r);
|
||||
}
|
||||
eprintln!("orientation {o}: 24 MP RGB8 in {best:.1} ms");
|
||||
assert!(best <= 50.0, "orientation {o} took {best:.1} ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF-structured EXIF block: IFD0 at offset 8 holding `entries`
|
||||
/// (`tag, type, count, 4-byte value field`), a zero next-IFD pointer, then `tail`.
|
||||
fn ifd(big: bool, entries: &[(u16, u16, u32, [u8; 4])], tail: &[u8]) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
|
||||
v.extend_from_slice(&u32b(8));
|
||||
v.extend_from_slice(&u16b(entries.len() as u16));
|
||||
for &(tag, ty, count, value) in entries {
|
||||
v.extend_from_slice(&u16b(tag));
|
||||
v.extend_from_slice(&u16b(ty));
|
||||
v.extend_from_slice(&u32b(count));
|
||||
v.extend_from_slice(&value);
|
||||
}
|
||||
v.extend_from_slice(&u32b(0));
|
||||
v.extend_from_slice(tail);
|
||||
v
|
||||
}
|
||||
|
||||
/// The 4-byte value field of a SHORT.
|
||||
fn short(v: u16, big: bool) -> [u8; 4] {
|
||||
let b = if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
[b[0], b[1], 0, 0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_orientation_entries_first_wins() {
|
||||
for big in [false, true] {
|
||||
let o = |v| (0x0112, 3, 1, short(v, big));
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(6), o(3)], &[])), 6);
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(3), o(6)], &[])), 3);
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(1), o(8)], &[])), 1);
|
||||
// A malformed first entry is ignored as a whole: a later good one does not count.
|
||||
let bad_first = ifd(big, &[(0x0112, 3, 2, short(6, big)), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&bad_first), 1);
|
||||
let long = |v: u32| (0x0112, 4, 1, if big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
let long_first = ifd(big, &[long(6), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&long_first), 6, "a LONG first entry counts");
|
||||
assert_eq!(&upright_exif(&long_first)[..], &ifd(big, &[long(1), o(1)], &[])[..], "each in its own width");
|
||||
let rational_first = ifd(big, &[(0x0112, 5, 1, short(6, big)), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&rational_first), 1);
|
||||
// Upright: every well-formed duplicate becomes 1, so no reader rotates twice.
|
||||
let dup = ifd(big, &[o(6), o(3)], &[]);
|
||||
let fixed = upright_exif(&dup);
|
||||
assert_eq!(fixed.len(), dup.len());
|
||||
assert_eq!(&fixed[..], &ifd(big, &[o(1), o(1)], &[])[..]);
|
||||
// Only a later duplicate disagrees: it is rewritten too.
|
||||
assert_eq!(&upright_exif(&ifd(big, &[o(1), o(8)], &[]))[..], &ifd(big, &[o(1), o(1)], &[])[..]);
|
||||
assert!(matches!(upright_exif(&ifd(big, &[o(1), o(1)], &[])), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_ifd_parsing_ignores_the_tag() {
|
||||
for big in [false, true] {
|
||||
let o6 = (0x0112, 3, 1, short(6, big));
|
||||
let good = ifd(big, &[o6], &[]);
|
||||
assert_eq!(exif_orientation(&good), 6);
|
||||
// Truncated before (or inside) the next-IFD pointer.
|
||||
for cut in 1..=4 {
|
||||
assert_eq!(exif_orientation(&good[..good.len() - cut]), 1, "cut {cut}");
|
||||
}
|
||||
// IFD offsets inside the 8-byte header.
|
||||
for off in 0..8u32 {
|
||||
let mut b = good.clone();
|
||||
b[4..8].copy_from_slice(&if big { off.to_be_bytes() } else { off.to_le_bytes() });
|
||||
assert_eq!(exif_orientation(&b), 1, "offset {off}");
|
||||
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
// Count must be exactly 1, type must be SHORT or LONG.
|
||||
let long6 = if big { 6u32.to_be_bytes() } else { 6u32.to_le_bytes() };
|
||||
for count in [0, 2, 3, u32::MAX] {
|
||||
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 3, count, short(6, big))], &[])), 1, "count {count}");
|
||||
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 4, count, long6)], &[])), 1, "LONG count {count}");
|
||||
}
|
||||
for ty in [1, 2, 5, 6, 7, 8, 9, 0, 0xFFFF] {
|
||||
let b = ifd(big, &[(0x0112, ty, 1, short(6, big))], &[]);
|
||||
assert_eq!(exif_orientation(&b), 1, "type {ty}");
|
||||
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)), "type {ty}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_exif_keeps_every_other_byte() {
|
||||
for big in [false, true] {
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
// IFD0: Make (12-byte ASCII stored after the IFD), Orientation 6, Software (inline ASCII),
|
||||
// ExifIFD pointer to a sub-IFD with its own entries.
|
||||
let ifd_end = 8 + 2 + 4 * 12 + 4;
|
||||
let make_at = ifd_end as u32;
|
||||
let sub_at = make_at + 12;
|
||||
let mut tail = b"PhotoCraftCo".to_vec();
|
||||
// Sub-IFD: one DateTimeOriginal-ish SHORT entry, then next-IFD 0.
|
||||
tail.extend_from_slice(&if big { 1u16.to_be_bytes() } else { 1u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&if big { 0x9000u16.to_be_bytes() } else { 0x9000u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&if big { 7u16.to_be_bytes() } else { 7u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&u32b(4));
|
||||
tail.extend_from_slice(b"0232");
|
||||
tail.extend_from_slice(&u32b(0));
|
||||
let e = ifd(big, &[(0x010F, 2, 12, u32b(make_at)), (0x0112, 3, 1, short(6, big)), (0x0131, 2, 4, *b"PC1\0"), (0x8769, 4, 1, u32b(sub_at))], &tail);
|
||||
let mut prefixed = b"Exif\0\0".to_vec();
|
||||
prefixed.extend_from_slice(&e);
|
||||
let fixed = upright_exif(&prefixed).into_owned();
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
assert_eq!(fixed.len(), prefixed.len());
|
||||
// Only the Orientation value (2 bytes in the second entry) differs.
|
||||
let value = 6 + 8 + 2 + 12 + 8;
|
||||
let changed: Vec<usize> = (0..fixed.len()).filter(|&i| fixed[i] != prefixed[i]).collect();
|
||||
assert_eq!(changed, vec![if big { value + 1 } else { value }], "big={big}");
|
||||
assert_eq!(&fixed[..value], &prefixed[..value]);
|
||||
assert_eq!(&fixed[value + 2..], &prefixed[value + 2..]);
|
||||
// The offset-based Make string and the inline Software tag are intact.
|
||||
assert_eq!(&fixed[6 + make_at as usize..][..12], b"PhotoCraftCo");
|
||||
assert!(fixed.windows(4).any(|w| w == b"PC1\0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_upright_size() {
|
||||
// Stored 32×16 landscape; Orientation 6 makes it a 16×32 portrait.
|
||||
let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let tall = jpeg_with_exif(&jpeg, &exif(6, false, false));
|
||||
let limits = Limits { max_width: 32, max_height: 16, ..Limits::default() };
|
||||
let opts = DecodeOptions { limits, ..Default::default() };
|
||||
assert!(matches!(decode_with(&tall, &opts), Err(CodecError::LimitExceeded(_))), "the upright portrait is too tall");
|
||||
// As stored it fits, and a 180° turn keeps the size.
|
||||
let kept = decode_with(&tall, &DecodeOptions { keep_orientation: true, ..opts.clone() }).unwrap();
|
||||
assert_eq!(kept.dimensions(), (32, 16));
|
||||
let flipped = decode_with(&jpeg_with_exif(&jpeg, &exif(3, false, false)), &opts).unwrap();
|
||||
assert_eq!(flipped.dimensions(), (32, 16));
|
||||
// The same holds for a TIFF, whose orientation lives in its own IFD0.
|
||||
let tiff = tiff_with_orientation(&[7; 32 * 16], 32, 16, 8);
|
||||
assert!(matches!(decode_with(&tiff, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
assert_eq!(decode_with(&tiff, &DecodeOptions::default()).unwrap().dimensions(), (16, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
|
||||
for big in [false, true] {
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let long = |v: u32| (0x0112u16, 4u16, 1u32, u32b(v));
|
||||
for o in 1..=8u32 {
|
||||
let e = ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(o)], b"tail");
|
||||
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big}");
|
||||
let fixed = upright_exif(&e);
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
// Still a LONG, all 4 value bytes set to 1 in the file's byte order; nothing else moved.
|
||||
assert_eq!(&fixed[..], &ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(1)], b"tail")[..], "o={o} big={big}");
|
||||
}
|
||||
// Out of range, including values that only fit in a LONG, reads as 1 and is left alone.
|
||||
for bad in [0, 9, 0x1_0006, u32::MAX] {
|
||||
let e = ifd(big, &[long(bad)], &[]);
|
||||
assert_eq!(exif_orientation(&e), 1, "{bad}");
|
||||
}
|
||||
// A LONG 6 in a JPEG opens upright.
|
||||
let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let img = decode(&jpeg_with_exif(&jpeg, &ifd(big, &[long(6)], &[]))).unwrap();
|
||||
assert_eq!(img.dimensions(), (16, 32));
|
||||
let kept = img.meta.exif.as_deref().unwrap();
|
||||
assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Photoshop's private TIFF tags (34377 image resources, 37724 ImageSourceData) travel through
|
||||
//! `Metadata` as opaque bytes: written when present, read from either byte order and from
|
||||
//! BigTIFF, and never a panic or an allocation on malformed input.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
fn with_tags(layout: ChannelLayout, sample: SampleType) -> Image {
|
||||
let mut img = synth(9, 7, layout, sample, 5, 0.3);
|
||||
img.meta.photoshop_resources = Some(b"8BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00\x48\x00\x00\x00\x01\x00\x01\x00\x48\x00\x00\x00\x01\x00\x01".to_vec());
|
||||
let mut layers = b"Adobe Photoshop Document Data Block\0".to_vec();
|
||||
layers.extend_from_slice(b"MIB8ryaL");
|
||||
layers.extend_from_slice(&4u32.to_le_bytes());
|
||||
layers.extend_from_slice(&[0, 0, 0, 0]);
|
||||
img.meta.photoshop_layers = Some(layers);
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_round_trip_through_the_encoder() {
|
||||
for (layout, sample) in [
|
||||
(ChannelLayout::Rgb, SampleType::U8),
|
||||
(ChannelLayout::Rgba, SampleType::U16),
|
||||
(ChannelLayout::Gray, SampleType::F32),
|
||||
(ChannelLayout::CmykA, SampleType::U8),
|
||||
] {
|
||||
for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] {
|
||||
let img = with_tags(layout, sample);
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap();
|
||||
// The encoder writes the running target's byte order.
|
||||
assert_eq!(bytes.starts_with(b"II"), tiff_writes_little_endian());
|
||||
let (res, layers) = tiff_photoshop_tags(&bytes);
|
||||
assert_eq!(res, img.meta.photoshop_resources.as_deref());
|
||||
assert_eq!(layers, img.meta.photoshop_layers.as_deref());
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.meta.photoshop_resources, img.meta.photoshop_resources, "{layout:?} {sample:?} {compression:?}");
|
||||
assert_eq!(back.meta.photoshop_layers, img.meta.photoshop_layers);
|
||||
assert_eq!(back.data(), img.data());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_tags_are_not_written() {
|
||||
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(tiff_photoshop_tags(&bytes), (None, None));
|
||||
assert!(decode(&bytes).unwrap().meta.photoshop_layers.is_none());
|
||||
img.meta.photoshop_layers = Some(Vec::new());
|
||||
img.meta.photoshop_resources = Some(Vec::new());
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(tiff_photoshop_tags(&bytes), (None, None));
|
||||
}
|
||||
|
||||
/// A minimal uncompressed 2×1 gray TIFF with a 37724 tag, in either byte order, classic or
|
||||
/// BigTIFF: `payload` is stored inline when it fits the value field, at an offset otherwise.
|
||||
fn hand_made(little: bool, big: bool, payload: &[u8]) -> Vec<u8> {
|
||||
let w16 = |v: u16| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let w32 = |v: u32| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let w64 = |v: u64| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(if little { b"II" } else { b"MM" });
|
||||
let header_len = if big { 16 } else { 8 };
|
||||
let pixels_at = header_len;
|
||||
let ifd_at = pixels_at + 2;
|
||||
f.extend(w16(if big { 43 } else { 42 }));
|
||||
if big {
|
||||
f.extend(w16(8));
|
||||
f.extend(w16(0));
|
||||
f.extend(w64(ifd_at as u64));
|
||||
} else {
|
||||
f.extend(w32(ifd_at as u32));
|
||||
}
|
||||
f.extend_from_slice(&[0x40, 0xc0]);
|
||||
// Entries: width, length, bits, compression, photometric, strip offsets, samples, rows per
|
||||
// strip, strip byte counts, 37724.
|
||||
let tags: [(u16, u16, u64, u64); 10] = [
|
||||
(256, 3, 1, 2),
|
||||
(257, 3, 1, 1),
|
||||
(258, 3, 1, 8),
|
||||
(259, 3, 1, 1),
|
||||
(262, 3, 1, 1),
|
||||
(273, 4, 1, pixels_at as u64),
|
||||
(277, 3, 1, 1),
|
||||
(278, 3, 1, 1),
|
||||
(279, 4, 1, 2),
|
||||
(37724, 7, payload.len() as u64, 0),
|
||||
];
|
||||
let entry_len = if big { 20 } else { 12 };
|
||||
let inline = if big { 8 } else { 4 };
|
||||
let count_len = if big { 8 } else { 2 };
|
||||
let next_len = if big { 8 } else { 4 };
|
||||
let data_at = ifd_at + count_len + tags.len() * entry_len + next_len;
|
||||
let mut entries = Vec::new();
|
||||
for (tag, ty, count, value) in tags {
|
||||
entries.extend(w16(tag));
|
||||
entries.extend(w16(ty));
|
||||
entries.extend(if big { w64(count) } else { w32(count as u32) });
|
||||
let mut v = Vec::new();
|
||||
if tag == 37724 {
|
||||
if payload.len() <= inline {
|
||||
v.extend_from_slice(payload);
|
||||
} else {
|
||||
v.extend(if big { w64(data_at as u64) } else { w32(data_at as u32) });
|
||||
}
|
||||
} else if ty == 3 {
|
||||
v.extend(w16(value as u16));
|
||||
} else {
|
||||
v.extend(if big { w64(value) } else { w32(value as u32) });
|
||||
}
|
||||
v.resize(inline, 0);
|
||||
entries.extend(v);
|
||||
}
|
||||
f.extend(if big { w64(tags.len() as u64) } else { w16(tags.len() as u16) });
|
||||
f.extend(entries);
|
||||
f.extend(if big { w64(0) } else { w32(0) });
|
||||
assert_eq!(f.len(), data_at);
|
||||
if payload.len() > inline {
|
||||
f.extend_from_slice(payload);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_made_files_in_every_layout() {
|
||||
let long = b"Adobe Photoshop Document Data Block\0MIB8".to_vec();
|
||||
for little in [false, true] {
|
||||
for big in [false, true] {
|
||||
for payload in [&b"ab"[..], &b"abcd"[..], &b"abcdefgh"[..], &long[..]] {
|
||||
let f = hand_made(little, big, payload);
|
||||
let (res, layers) = tiff_photoshop_tags(&f);
|
||||
assert_eq!(res, None);
|
||||
assert_eq!(layers, Some(payload), "little {little} big {big} len {}", payload.len());
|
||||
let img = decode(&f).unwrap();
|
||||
assert_eq!(img.dimensions(), (2, 1));
|
||||
assert_eq!(img.meta.photoshop_layers.as_deref(), Some(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tags_read_as_absent_without_panicking() {
|
||||
let long = vec![7u8; 40];
|
||||
for little in [false, true] {
|
||||
for big in [false, true] {
|
||||
let f = hand_made(little, big, &long);
|
||||
// Truncated anywhere: never a panic; the payload is absent once it is cut.
|
||||
for cut in 0..f.len() {
|
||||
let (_, layers) = tiff_photoshop_tags(&f[..cut]);
|
||||
if cut < f.len() {
|
||||
assert!(layers.is_none_or(|l| l.len() == 40));
|
||||
}
|
||||
let _ = decode(&f[..cut]);
|
||||
}
|
||||
// Bit flips in the directory.
|
||||
for i in 0..f.len() - 40 {
|
||||
let mut g = f.clone();
|
||||
g[i] ^= 0xff;
|
||||
let _ = tiff_photoshop_tags(&g);
|
||||
let _ = decode(&g);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(tiff_photoshop_tags(b""), (None, None));
|
||||
assert_eq!(tiff_photoshop_tags(b"II*\0"), (None, None));
|
||||
assert_eq!(tiff_photoshop_tags(b"MM\0*\xff\xff\xff\xff"), (None, None));
|
||||
// A count that overflows the file.
|
||||
let mut f = hand_made(true, false, &long);
|
||||
let at = f.windows(2).position(|w| w == 37724u16.to_le_bytes()).unwrap();
|
||||
f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(tiff_photoshop_tags(&f).1, None);
|
||||
}
|
||||
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user