mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 06:34:12 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4717de1398 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
# Custom runner labels actionlint can't know about.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- macos-release # org larger runner (M2 Pro), used by release.yml
|
||||
@@ -18,51 +18,18 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
|
||||
# that lands while main's run is in progress waits for it, so every main state that gets a run is
|
||||
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get all three.
|
||||
# Other PRs get Linux, plus macOS and Windows when they touch code whose behaviour differs by
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
platforms:
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
||||
run: |
|
||||
all='["ubuntu-latest","macos-latest","windows-latest"]'
|
||||
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -84,38 +51,11 @@ 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 --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. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
- 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
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
@@ -174,13 +114,11 @@ 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
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
# uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- .github/workflows/release.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
@@ -50,13 +51,8 @@ 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 --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
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# 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,9 +34,7 @@ jobs:
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -30,16 +30,12 @@ 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:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
@@ -67,19 +63,13 @@ jobs:
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
runs-on: macos-15
|
||||
environment: release
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -100,18 +90,7 @@ 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
|
||||
@@ -133,20 +112,11 @@ 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 }}
|
||||
@@ -171,8 +141,6 @@ 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:
|
||||
@@ -186,7 +154,7 @@ jobs:
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -199,18 +167,12 @@ 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 zsync
|
||||
desktop-file-utils appstream python3-yaml
|
||||
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"
|
||||
@@ -222,9 +184,6 @@ 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: |
|
||||
@@ -235,9 +194,6 @@ 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:
|
||||
@@ -296,12 +252,6 @@ 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
|
||||
@@ -324,8 +274,6 @@ 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$'
|
||||
@@ -339,7 +287,7 @@ jobs:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
environment: release
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -360,8 +308,6 @@ 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
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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
|
||||
|
||||
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
|
||||
# change the Windows packaging itself.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
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,4 +8,3 @@ 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, 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/`.
|
||||
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/`.
|
||||
|
||||
## 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`). **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.
|
||||
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`).
|
||||
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.
|
||||
@@ -131,21 +131,3 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
nothing is silently dropped from `.pcraft` saves. When you add a doc field, add it to
|
||||
`crates/format/src/manifest.rs` and `convert.rs` with `#[serde(default)]` so older files still load.
|
||||
If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown PSD blocks verbatim.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
+1
-22
@@ -10,35 +10,16 @@ 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/it.tsv` | Italian 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`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.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) |
|
||||
| `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) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
|
||||
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
|
||||
| `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.
|
||||
@@ -49,7 +30,6 @@ 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)):
|
||||
@@ -78,7 +58,6 @@ 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
+28
-158
@@ -8,8 +8,6 @@ version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
dependencies = [
|
||||
"enumn",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -132,7 +130,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
@@ -578,9 +575,6 @@ 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"
|
||||
@@ -1025,15 +1019,6 @@ dependencies = [
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -1197,7 +1182,6 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1226,8 +1210,6 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"ron",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1252,8 +1234,6 @@ dependencies = [
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
"profiling",
|
||||
"ron",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"web-sys",
|
||||
@@ -1295,7 +1275,6 @@ dependencies = [
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"winit",
|
||||
@@ -1364,7 +1343,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1414,17 +1392,6 @@ 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"
|
||||
@@ -1443,7 +1410,6 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-general-category",
|
||||
@@ -1650,7 +1616,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2039,15 +2004,6 @@ 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"
|
||||
@@ -2460,16 +2416,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -2726,24 +2672,6 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
@@ -3300,7 +3228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3458,17 +3386,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3483,16 +3407,13 @@ dependencies = [
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3504,7 +3425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
@@ -3526,7 +3447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -3539,7 +3460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -3547,7 +3468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -3555,10 +3476,8 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
@@ -3567,7 +3486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -3575,7 +3494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -3590,7 +3509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3601,7 +3520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3631,7 +3550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
@@ -3649,14 +3568,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -3671,16 +3590,9 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
@@ -3704,14 +3616,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3722,7 +3634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3737,7 +3649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -3748,7 +3660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -3758,7 +3670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
@@ -3767,7 +3679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -3779,7 +3691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -3790,7 +3702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -3804,7 +3716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
@@ -3813,7 +3725,6 @@ dependencies = [
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
@@ -3824,7 +3735,6 @@ dependencies = [
|
||||
"photocraft-gpu",
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
@@ -3832,13 +3742,11 @@ dependencies = [
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -3849,7 +3757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4484,20 +4392,6 @@ 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"
|
||||
@@ -4813,9 +4707,6 @@ 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"
|
||||
@@ -4994,15 +4885,6 @@ 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"
|
||||
@@ -5286,12 +5168,6 @@ 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"
|
||||
@@ -6298,12 +6174,6 @@ 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"
|
||||
@@ -6415,7 +6285,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
|
||||
@@ -16,8 +16,3 @@ 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.
|
||||
@@ -52,7 +52,6 @@
|
||||
<a href="#built-for-agents">Agents</a> ·
|
||||
<a href="#under-the-hood">Under the hood</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a> ·
|
||||
<a href="https://discord.gg/artcraft">Discord</a>
|
||||
</p>
|
||||
@@ -179,7 +178,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 34 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
@@ -199,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, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), 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.
|
||||
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.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
@@ -232,9 +231,6 @@ 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
|
||||
```
|
||||
@@ -259,13 +255,6 @@ 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:
|
||||
@@ -275,16 +264,6 @@ 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
|
||||
@@ -313,51 +292,6 @@ PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs
|
||||
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
|
||||
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `photocraft-<ver>-windows-x64.msi` | `photocraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `photocraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `photocraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `photocraft-<ver>-linux-x86_64.AppImage` | `photocraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `photocraft-<ver>-linux-x86_64.flatpak` | `photocraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `photocraft-<ver>-linux-x86_64.deb` | `photocraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `photocraft-<ver>-linux-x86_64.rpm` | `photocraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `photocraft-<ver>-linux-x86_64.tar.gz` | `photocraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `photocraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `photocraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
@@ -370,7 +304,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/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/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/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) |
|
||||
|
||||
@@ -424,6 +358,5 @@ 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
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
|
||||
ArtCraft
|
||||
+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. 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.
|
||||
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.
|
||||
@@ -5,11 +5,6 @@ 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
|
||||
|
||||
|
||||
+68
-115
@@ -14,20 +14,15 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
|
||||
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 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 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>]
|
||||
@@ -38,12 +33,8 @@ 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, 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.
|
||||
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).
|
||||
";
|
||||
|
||||
struct Args {
|
||||
@@ -51,67 +42,43 @@ struct Args {
|
||||
flags: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
},
|
||||
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",
|
||||
];
|
||||
|
||||
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
|
||||
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
|
||||
fn parse(args: &[String]) -> Result<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 let Some(s) = args.get(i) {
|
||||
if s == "--help" || s == "-h" {
|
||||
return Ok(None);
|
||||
}
|
||||
while i < args.len() {
|
||||
let s = &args[i];
|
||||
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 sub.values.contains(&s.as_str()) {
|
||||
} else if VALUE_FLAGS.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 sub.bare.contains(&s.as_str()) {
|
||||
a.flags.push((s.clone(), None));
|
||||
} else if s.starts_with("--") {
|
||||
return Err(unknown(s));
|
||||
a.flags.push((s.clone(), None));
|
||||
} else {
|
||||
a.positional.push(s.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(Some(a))
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
impl Args {
|
||||
@@ -131,7 +98,22 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = write!(err, "{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
match cmd.as_str() {
|
||||
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),
|
||||
"-h" | "--help" | "help" => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
@@ -140,25 +122,12 @@ 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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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}");
|
||||
other => {
|
||||
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match (sub.run)(&parsed, out, err) {
|
||||
match r {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}");
|
||||
@@ -168,14 +137,10 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
}
|
||||
|
||||
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
|
||||
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
|
||||
let mut o = ExportOptions::default();
|
||||
if let Some(q) = a.get("--quality") {
|
||||
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
|
||||
o.encode.jpeg_quality = q;
|
||||
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
|
||||
o.encode.webp_quality = q;
|
||||
o.encode.webp_lossless = false;
|
||||
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
|
||||
o.encode.jpeg_quality = q.clamp(1, 100);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
@@ -195,10 +160,9 @@ 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"), &opts, None).map_err(|e| e.to_string())?;
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
}
|
||||
@@ -243,7 +207,6 @@ 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) => {
|
||||
@@ -253,7 +216,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| format!("--new: {e}"))?;
|
||||
h.command_run("file.new", p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
|
||||
}
|
||||
@@ -266,25 +229,29 @@ 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"), &opts).map_err(|e| e.to_string())?;
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).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: 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).
|
||||
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
|
||||
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
|
||||
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
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()
|
||||
}
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
@@ -299,29 +266,16 @@ 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 = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
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 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 {
|
||||
@@ -333,7 +287,6 @@ 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,63 +43,6 @@ 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");
|
||||
@@ -143,37 +86,6 @@ 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");
|
||||
@@ -209,15 +121,6 @@ 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]
|
||||
@@ -252,157 +155,6 @@ 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]
|
||||
@@ -466,38 +218,6 @@ 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");
|
||||
@@ -517,32 +237,3 @@ 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();
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
//! 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,11 +6,6 @@ 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,27 +4,16 @@
|
||||
<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-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" 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; 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; } }
|
||||
#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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="photocraft_canvas"></canvas>
|
||||
<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>
|
||||
<div id="photocraft_loading">Loading PhotoCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,18 +28,3 @@ 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", "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",
|
||||
"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",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
@@ -144,12 +144,6 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_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,11 +7,6 @@ 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
|
||||
|
||||
@@ -29,50 +24,21 @@ 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", "persistence"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
||||
arboard = { version = "3", 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"] }
|
||||
# The macOS menu bar (mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
|
||||
# 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"] }
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSGraphicsContext", "NSMenu", "NSMenuItem", "NSResponder"] }
|
||||
|
||||
[[test]]
|
||||
name = "mac_menu_appkit"
|
||||
harness = false
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
//! 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, RenderingMode};
|
||||
use photocraft_engine::prefs::GpuBackend;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The marker's file name in the config directory.
|
||||
@@ -154,19 +154,6 @@ 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() {
|
||||
@@ -186,22 +173,6 @@ 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 {
|
||||
@@ -240,7 +211,8 @@ 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> {
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
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)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
@@ -253,19 +225,14 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 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).
|
||||
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;
|
||||
@@ -283,10 +250,6 @@ 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());
|
||||
@@ -394,15 +357,14 @@ impl Sentinel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
/// `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) {
|
||||
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);
|
||||
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)
|
||||
(backend, use_gpu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -414,17 +376,6 @@ 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] {
|
||||
@@ -523,21 +474,6 @@ 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 {
|
||||
@@ -577,16 +513,7 @@ mod tests {
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
// 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 (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.
|
||||
@@ -615,48 +542,14 @@ mod tests {
|
||||
let dir = temp_dir("prefs");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("preferences.json");
|
||||
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
assert_eq!(read_prefs(None), (Auto, true));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
|
||||
assert_eq!(read_prefs(Some(&p)), (Dx12, false));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
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));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK on
|
||||
//! macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in
|
||||
//! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
|
||||
//!
|
||||
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
|
||||
//! gets `id#2` for its second copy, as muda ids must be unique.
|
||||
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
|
||||
//! else (labels, enabled, checked) is updated in place.
|
||||
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
|
||||
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
|
||||
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
|
||||
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
|
||||
use photocraft_ui_egui::native_menu::{self, Backend, Chord, Event, MenuBar, MenuRole, Node, Standard};
|
||||
|
||||
enum Handle {
|
||||
Plain(MenuItem),
|
||||
Check(CheckMenuItem),
|
||||
}
|
||||
|
||||
/// One native item.
|
||||
struct Entry {
|
||||
handle: Handle,
|
||||
/// The command id (several entries can share one).
|
||||
command: String,
|
||||
chord: Option<Chord>,
|
||||
label: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// How an item was chosen, read from AppKit's current event while it calls the handler.
|
||||
struct Chosen {
|
||||
id: String,
|
||||
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
|
||||
key: bool,
|
||||
mods: [bool; 4],
|
||||
}
|
||||
|
||||
pub struct MacMenu {
|
||||
menu: Option<Menu>,
|
||||
/// By muda id.
|
||||
entries: HashMap<String, Entry>,
|
||||
rx: Receiver<Chosen>,
|
||||
structure: u64,
|
||||
}
|
||||
|
||||
impl MacMenu {
|
||||
pub fn new(ctx: &egui::Context) -> MacMenu {
|
||||
let (tx, rx) = channel();
|
||||
let repaint = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
|
||||
// AppKit calls this on the main thread, from the event that chose the item: a key
|
||||
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
|
||||
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
|
||||
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
|
||||
let flags = event.as_ref().map(|ev| ev.modifierFlags()).unwrap_or(NSEventModifierFlags::empty());
|
||||
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
|
||||
.map(|m| flags.contains(m));
|
||||
let _ = tx.send(Chosen { id: e.id.0, key, mods });
|
||||
// Wake egui, so the item runs now rather than on the next mouse move.
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 }
|
||||
}
|
||||
|
||||
fn build(&mut self, bar: &MenuBar) {
|
||||
if let Some(old) = self.menu.take() {
|
||||
old.remove_for_nsapp();
|
||||
}
|
||||
self.entries.clear();
|
||||
let menu = Menu::new();
|
||||
let mut special = Vec::new();
|
||||
for m in &bar.menus {
|
||||
let sub = Submenu::new(escape(&m.title), true);
|
||||
self.append(&sub, &m.children);
|
||||
let _ = menu.append(&sub);
|
||||
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
|
||||
special.push((m.role, sub));
|
||||
}
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
// Only once the menu is NSApp's: muda looks the submenus up in the installed main menu.
|
||||
for (role, sub) in special {
|
||||
match role {
|
||||
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
|
||||
_ => sub.set_as_help_menu_for_nsapp(),
|
||||
}
|
||||
}
|
||||
self.menu = Some(menu);
|
||||
self.structure = native_menu::structure_key(bar);
|
||||
}
|
||||
|
||||
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Separator => {
|
||||
let _ = sub.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Node::Submenu { label, children, .. } => {
|
||||
let s = Submenu::new(escape(label), !children.is_empty());
|
||||
self.append(&s, children);
|
||||
let _ = sub.append(&s);
|
||||
}
|
||||
Node::Standard(item) => {
|
||||
let p = match item {
|
||||
Standard::Services => PredefinedMenuItem::services(None),
|
||||
Standard::HideOthers => PredefinedMenuItem::hide_others(None),
|
||||
Standard::ShowAll => PredefinedMenuItem::show_all(None),
|
||||
Standard::Minimize => PredefinedMenuItem::minimize(None),
|
||||
Standard::Zoom => PredefinedMenuItem::maximize(Some(photocraft_ui_egui::i18n::t("Zoom"))),
|
||||
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
|
||||
Standard::CloseWindow => PredefinedMenuItem::close_window(None),
|
||||
};
|
||||
let _ = sub.append(&p);
|
||||
}
|
||||
Node::Item(it) => {
|
||||
let id = self.unique_id(&it.id);
|
||||
let chord = it.shortcut.as_deref().and_then(Chord::parse);
|
||||
let accel = chord.as_ref().filter(|c| c.native_ok()).and_then(accelerator);
|
||||
let handle = match it.checked {
|
||||
Some(c) => {
|
||||
let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel);
|
||||
let _ = sub.append(&h);
|
||||
Handle::Check(h)
|
||||
}
|
||||
None => {
|
||||
let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel);
|
||||
let _ = sub.append(&h);
|
||||
Handle::Plain(h)
|
||||
}
|
||||
};
|
||||
self.entries.insert(id, Entry { handle, command: it.id.clone(), chord, label: it.label.clone(), enabled: it.enabled });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// muda ids must be unique: the second `edit.keyboardShortcuts` becomes `…#2`.
|
||||
fn unique_id(&self, id: &str) -> String {
|
||||
let mut unique = id.to_string();
|
||||
let mut n = 2;
|
||||
while self.entries.contains_key(&unique) {
|
||||
unique = format!("{id}#{n}");
|
||||
n += 1;
|
||||
}
|
||||
unique
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for MacMenu {
|
||||
fn sync(&mut self, bar: &MenuBar) {
|
||||
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure {
|
||||
self.build(bar);
|
||||
return;
|
||||
}
|
||||
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
|
||||
for e in self.entries.values_mut() {
|
||||
let Some(it) = items.get(e.command.as_str()) else { continue };
|
||||
if e.label != it.label {
|
||||
let text = escape(&it.label);
|
||||
match &e.handle {
|
||||
Handle::Plain(h) => h.set_text(text),
|
||||
Handle::Check(h) => h.set_text(text),
|
||||
}
|
||||
e.label = it.label.clone();
|
||||
}
|
||||
if e.enabled != it.enabled {
|
||||
match &e.handle {
|
||||
Handle::Plain(h) => h.set_enabled(it.enabled),
|
||||
Handle::Check(h) => h.set_enabled(it.enabled),
|
||||
}
|
||||
e.enabled = it.enabled;
|
||||
}
|
||||
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
|
||||
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
|
||||
&& h.is_checked() != c
|
||||
{
|
||||
h.set_checked(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) -> Vec<Event> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(chosen) = self.rx.try_recv() {
|
||||
// Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled.
|
||||
let Some(e) = self.entries.get(&chosen.id) else { continue };
|
||||
if e.command == native_menu::HIDE {
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
NSApplication::sharedApplication(mtm).hide(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
|
||||
// menu (no ⌘ or ⌃, and the item's shortcut needs one). The key is the item's (AppKit
|
||||
// matched on it); the modifiers are the ones held.
|
||||
let [cmd, ctrl, alt, shift] = chosen.mods;
|
||||
let key =
|
||||
e.chord.as_ref().filter(|c| chosen.key && (cmd || ctrl || !(c.cmd || c.ctrl))).map(|c| Chord { cmd, ctrl, alt, shift, key: c.key.clone() });
|
||||
out.push(match key {
|
||||
Some(chord) if e.command != native_menu::MINIMIZE => Event::Key(chord),
|
||||
_ => Event::Click(e.command.clone()),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the menu bar now (in eframe's creator closure, so winit's default menu doesn't stay).
|
||||
pub fn install(ctx: &egui::Context, app: &photocraft_ui_egui::PhotocraftApp) -> Option<native_menu::NativeMenu> {
|
||||
let language = &app.session.prefs().interface.language;
|
||||
let lang = photocraft_ui_egui::i18n::Lang::from_pref(language);
|
||||
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(app), lang, language).bar;
|
||||
// Never crash for a menu: if AppKit refuses, keep the in-window menus.
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut menu = MacMenu::new(ctx);
|
||||
menu.sync(&bar);
|
||||
menu
|
||||
}));
|
||||
match built {
|
||||
Ok(menu) => Some(native_menu::NativeMenu::new(Box::new(menu))),
|
||||
Err(_) => {
|
||||
log::warn!("couldn't install the macOS menu bar; using the in-window menus");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&&")
|
||||
}
|
||||
|
||||
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
|
||||
fn accelerator(c: &Chord) -> Option<Accelerator> {
|
||||
let mut mods = Modifiers::empty();
|
||||
for (on, m) in [(c.cmd, Modifiers::META), (c.ctrl, Modifiers::CONTROL), (c.alt, Modifiers::ALT), (c.shift, Modifiers::SHIFT)] {
|
||||
if on {
|
||||
mods |= m;
|
||||
}
|
||||
}
|
||||
const LETTERS: [Code; 26] = [
|
||||
Code::KeyA,
|
||||
Code::KeyB,
|
||||
Code::KeyC,
|
||||
Code::KeyD,
|
||||
Code::KeyE,
|
||||
Code::KeyF,
|
||||
Code::KeyG,
|
||||
Code::KeyH,
|
||||
Code::KeyI,
|
||||
Code::KeyJ,
|
||||
Code::KeyK,
|
||||
Code::KeyL,
|
||||
Code::KeyM,
|
||||
Code::KeyN,
|
||||
Code::KeyO,
|
||||
Code::KeyP,
|
||||
Code::KeyQ,
|
||||
Code::KeyR,
|
||||
Code::KeyS,
|
||||
Code::KeyT,
|
||||
Code::KeyU,
|
||||
Code::KeyV,
|
||||
Code::KeyW,
|
||||
Code::KeyX,
|
||||
Code::KeyY,
|
||||
Code::KeyZ,
|
||||
];
|
||||
const DIGITS: [Code; 10] =
|
||||
[Code::Digit0, Code::Digit1, Code::Digit2, Code::Digit3, Code::Digit4, Code::Digit5, Code::Digit6, Code::Digit7, Code::Digit8, Code::Digit9];
|
||||
const F: [Code; 24] = [
|
||||
Code::F1,
|
||||
Code::F2,
|
||||
Code::F3,
|
||||
Code::F4,
|
||||
Code::F5,
|
||||
Code::F6,
|
||||
Code::F7,
|
||||
Code::F8,
|
||||
Code::F9,
|
||||
Code::F10,
|
||||
Code::F11,
|
||||
Code::F12,
|
||||
Code::F13,
|
||||
Code::F14,
|
||||
Code::F15,
|
||||
Code::F16,
|
||||
Code::F17,
|
||||
Code::F18,
|
||||
Code::F19,
|
||||
Code::F20,
|
||||
Code::F21,
|
||||
Code::F22,
|
||||
Code::F23,
|
||||
Code::F24,
|
||||
];
|
||||
let k = c.key.as_str();
|
||||
let mut chars = k.chars();
|
||||
let code = match (chars.next(), chars.next()) {
|
||||
(Some(ch), None) if ch.is_ascii_alphabetic() => *LETTERS.get((ch.to_ascii_uppercase() as usize).checked_sub('A' as usize)?)?,
|
||||
(Some(ch), None) if ch.is_ascii_digit() => *DIGITS.get((ch as usize).checked_sub('0' as usize)?)?,
|
||||
_ if c.is_function_key() => *F.get(k.get(1..)?.parse::<usize>().ok()?.checked_sub(1)?)?,
|
||||
_ => match k {
|
||||
"[" => Code::BracketLeft,
|
||||
"]" => Code::BracketRight,
|
||||
"\\" => Code::Backslash,
|
||||
"/" => Code::Slash,
|
||||
"=" => Code::Equal,
|
||||
"-" => Code::Minus,
|
||||
"'" => Code::Quote,
|
||||
";" => Code::Semicolon,
|
||||
"," => Code::Comma,
|
||||
"." => Code::Period,
|
||||
"`" => Code::Backquote,
|
||||
"Delete" | "Backspace" => Code::Backspace,
|
||||
"Enter" if !mods.is_empty() => Code::Enter,
|
||||
"Tab" if !mods.is_empty() => Code::Tab,
|
||||
"Space" if !mods.is_empty() => Code::Space,
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(Accelerator::new(mods, code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every shortcut the Mac menu shows becomes a key equivalent (or stays egui's, for bare keys).
|
||||
#[test]
|
||||
fn every_menu_shortcut_maps_to_a_key_equivalent() {
|
||||
let app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(&app), photocraft_ui_egui::i18n::Lang::EN, "auto").bar;
|
||||
let mut native = 0;
|
||||
for it in bar.items() {
|
||||
let Some(c) = it.shortcut.as_deref().and_then(Chord::parse) else { continue };
|
||||
if c.native_ok() {
|
||||
assert!(accelerator(&c).is_some(), "{} ({}) has no key equivalent", c.portable(), it.id);
|
||||
native += 1;
|
||||
}
|
||||
}
|
||||
assert!(native > 70, "{native} key equivalents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_and_command_stay_distinct() {
|
||||
let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap();
|
||||
let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+27
-256
@@ -2,15 +2,12 @@
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
|
||||
//!
|
||||
//! `--in-window-menus` (or `PHOTOCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
//!
|
||||
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
|
||||
//! The first line must authenticate; subsequent request lines get reply lines.
|
||||
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
|
||||
@@ -26,12 +23,7 @@ mod app_icon;
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[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;
|
||||
@@ -40,7 +32,6 @@ 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;
|
||||
@@ -48,111 +39,24 @@ 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";
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
|
||||
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
|
||||
/// keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// 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_decorations(!CUSTOM_TITLEBAR)
|
||||
.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> = 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_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
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);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--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" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--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),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--safe-gpu" => safe_gpu = true,
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -163,16 +67,6 @@ 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")]
|
||||
@@ -222,25 +116,34 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _tablet = tablet::install_macos(&stylus_feed);
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
// Read the main display's ICC profile while the window opens (colour-managed canvas).
|
||||
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 = 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());
|
||||
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()
|
||||
};
|
||||
// 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, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
|
||||
let (pref, _) = gpu_startup::read_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_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
let plan = gpu_startup::plan(pref, 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(""));
|
||||
}
|
||||
@@ -260,65 +163,31 @@ 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");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
// 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()));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
// 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 = 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.fallback = plan.reason.clone().or_else(|| gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone());
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
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
|
||||
if plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
@@ -326,33 +195,13 @@ 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()}))
|
||||
@@ -367,25 +216,17 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
|
||||
if !in_window_menus {
|
||||
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
// 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, display);
|
||||
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
|
||||
// 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, None);
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
@@ -397,75 +238,5 @@ 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,145 +1,45 @@
|
||||
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`; #569).
|
||||
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`).
|
||||
//!
|
||||
//! 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).
|
||||
//! 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).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
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;
|
||||
}
|
||||
/// Starts reading the main display's profile in the background.
|
||||
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
});
|
||||
Some(rx)
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
/// 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")]
|
||||
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}") }));
|
||||
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;
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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)
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
|
||||
@@ -187,74 +87,12 @@ mod tests {
|
||||
assert!(base64_decode("a$==").is_none());
|
||||
}
|
||||
|
||||
/// The detected profile (when there is one) parses as an RGB profile.
|
||||
#[test]
|
||||
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 `sleep` (found on PATH: NixOS and the Nix build sandbox have no /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("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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-266
@@ -3,10 +3,11 @@
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_format::Autosaver;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::{Recovered, Services};
|
||||
use photocraft_ui_egui::Services;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -14,23 +15,15 @@ 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", "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",
|
||||
"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",
|
||||
];
|
||||
|
||||
/// 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"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
];
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] =
|
||||
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
|
||||
|
||||
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
|
||||
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
@@ -71,63 +64,9 @@ 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| {
|
||||
@@ -139,8 +78,6 @@ 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())
|
||||
@@ -155,27 +92,13 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_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()?;
|
||||
// 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());
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
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();
|
||||
@@ -191,7 +114,6 @@ 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())
|
||||
@@ -217,17 +139,37 @@ 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())?;
|
||||
@@ -237,8 +179,6 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,174 +243,3 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! The macOS menu bar end to end, on the main thread (`harness = false`): install it through the
|
||||
//! real muda backend (`src/mac_menu.rs`), read AppKit's menu bar back, open a document and see the
|
||||
//! items update in place, then choose items the two ways AppKit can: a key equivalent (a real ⌘R
|
||||
//! key-down through `-[NSMenu performKeyEquivalent:]`) must reach egui as the key press it was, and
|
||||
//! a click (`-[NSMenu performActionForItemAtIndex:]`) must run as a click, also when the click is
|
||||
//! ↩ inside the open menu.
|
||||
|
||||
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
|
||||
// unit tests compile here too without a harness to run them).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "../src/mac_menu.rs"]
|
||||
#[allow(unused_imports, dead_code)]
|
||||
mod mac_menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags, NSEventSubtype, NSEventType, NSMenu};
|
||||
use objc2_foundation::{NSPoint, NSString};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::native_menu;
|
||||
use serde_json::json;
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let nsapp = NSApplication::sharedApplication(mtm);
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let titles = |menu: &NSMenu| -> Vec<String> {
|
||||
(0..menu.numberOfItems()).filter_map(|i| menu.itemAtIndex(i)).filter(|it| !it.isSeparatorItem()).map(|it| it.title().to_string()).collect()
|
||||
};
|
||||
let submenu = |menu: &NSMenu, title: &str| -> Retained<NSMenu> {
|
||||
(0..menu.numberOfItems())
|
||||
.filter_map(|i| menu.itemAtIndex(i))
|
||||
.find(|it| it.title().to_string() == title)
|
||||
.and_then(|it| it.submenu())
|
||||
.unwrap_or_else(|| panic!("no {title} submenu in {:?}", titles(menu)))
|
||||
};
|
||||
let index = |menu: &NSMenu, title: &str| -> isize {
|
||||
(0..menu.numberOfItems())
|
||||
.find(|&i| menu.itemAtIndex(i).is_some_and(|it| it.title().to_string() == title))
|
||||
.unwrap_or_else(|| panic!("no {title} in {:?}", titles(menu)))
|
||||
};
|
||||
let main_menu = || nsapp.mainMenu().expect("a main menu");
|
||||
// Make `event` AppKit's current event, as the run loop does before dispatching it.
|
||||
let dequeue = |event: &NSEvent| {
|
||||
nsapp.postEvent_atStart(event, true);
|
||||
let mode = NSString::from_str("kCFRunLoopDefaultMode");
|
||||
let got = nsapp.nextEventMatchingMask_untilDate_inMode_dequeue(NSEventMask::Any, None, &mode, true).expect("the posted event");
|
||||
assert_eq!(got.r#type(), event.r#type());
|
||||
got
|
||||
};
|
||||
let key_down = |chars: &str, code: u16, flags: NSEventModifierFlags| {
|
||||
let s = NSString::from_str(chars);
|
||||
NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode(
|
||||
NSEventType::KeyDown,
|
||||
NSPoint::new(0.0, 0.0),
|
||||
flags,
|
||||
0.0,
|
||||
0,
|
||||
None,
|
||||
&s,
|
||||
&s,
|
||||
false,
|
||||
code,
|
||||
)
|
||||
.expect("a key event")
|
||||
};
|
||||
let other = || {
|
||||
NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
|
||||
NSEventType::ApplicationDefined,
|
||||
NSPoint::new(0.0, 0.0),
|
||||
NSEventModifierFlags::empty(),
|
||||
0.0,
|
||||
0,
|
||||
None,
|
||||
NSEventSubtype::ApplicationActivated.0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.expect("an application-defined event")
|
||||
};
|
||||
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut PhotocraftApp, raw: egui::RawInput| {
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
native_menu::run(app, ui.ctx());
|
||||
photocraft_ui_egui::shortcuts::handle(app, ui.ctx());
|
||||
native_menu::sync(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
app.services.native_menu = mac_menu::install(&ctx, &app);
|
||||
assert!(app.services.native_menu.is_some(), "the menu bar installed");
|
||||
|
||||
// The Mac layout, as AppKit has it.
|
||||
let bar = main_menu();
|
||||
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
|
||||
assert_eq!(tops, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]);
|
||||
let app_menu = submenu(&bar, "PhotoCraft");
|
||||
let names = titles(&app_menu);
|
||||
for want in ["About PhotoCraft", "Settings", "Language", "Appearance", "Hide PhotoCraft", "Quit PhotoCraft"] {
|
||||
assert!(names.iter().any(|n| n == want), "{want} missing from the app menu: {names:?}");
|
||||
}
|
||||
let quit = app_menu.itemAtIndex(index(&app_menu, "Quit PhotoCraft")).expect("Quit");
|
||||
assert_eq!(quit.keyEquivalent().to_string(), "q");
|
||||
assert_eq!(quit.keyEquivalentModifierMask(), NSEventModifierFlags::Command);
|
||||
assert!(!titles(&submenu(&bar, "File")).iter().any(|n| n == "Exit"), "Exit moved to the app menu as Quit");
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
assert_eq!(nsapp.helpMenu().map(|m| m.title().to_string()).as_deref(), Some("Help"), "AppKit knows the Help menu");
|
||||
|
||||
// Opening a document enables Layer › New › Layer… in place: same NSMenu, no rebuild.
|
||||
let new_layer = |bar: &NSMenu| {
|
||||
let new = submenu(&submenu(bar, "Layer"), "New");
|
||||
new.itemAtIndex(index(&new, "Layer…")).expect("Layer…").isEnabled()
|
||||
};
|
||||
assert!(!new_layer(&bar), "no document: New Layer is disabled");
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).expect("a document");
|
||||
frame(&mut app, egui::RawInput::default());
|
||||
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "opening a document updated the menu, not rebuilt it");
|
||||
assert!(new_layer(&main_menu()), "with a document New Layer is enabled");
|
||||
|
||||
// A key equivalent: ⌘R reaches egui as ⌘R and toggles View › Rulers once.
|
||||
let rulers = app.ui.extras.rulers;
|
||||
let press = dequeue(&key_down("r", 15, NSEventModifierFlags::Command));
|
||||
assert!(main_menu().performKeyEquivalent(&press), "⌘R is View › Rulers' key equivalent");
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
let keys: Vec<_> = raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
|
||||
.collect();
|
||||
assert_eq!(keys, [(egui::Key::R, true, true), (egui::Key::R, false, true)], "the key press, then its release");
|
||||
frame(&mut app, raw);
|
||||
let ran: Vec<String> = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched");
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "⌘R toggled Rulers once");
|
||||
|
||||
// A click: View › Rulers chosen with the mouse runs the command.
|
||||
let view = submenu(&main_menu(), "View");
|
||||
let at = index(&view, "Rulers");
|
||||
dequeue(&other());
|
||||
view.performActionForItemAtIndex(at);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
assert!(raw.events.is_empty(), "a click is no key press: {:?}", raw.events);
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, rulers, "the click toggled Rulers back");
|
||||
|
||||
// ↩ inside the open menu is a key-down without ⌘: still a click.
|
||||
dequeue(&key_down("\r", 36, NSEventModifierFlags::empty()));
|
||||
view.performActionForItemAtIndex(at);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
assert!(raw.events.is_empty(), "↩ in the menu is a click: {:?}", raw.events);
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {}
|
||||
@@ -1,334 +0,0 @@
|
||||
assets/icons/lasso-magnetic.svg
|
||||
|
||||
Adapted from "Magnetic Lasso Tool" by Lil' Seal (failureiscool) from Noun Project,
|
||||
https://thenounproject.com/icon/magnetic-lasso-tool-177513/
|
||||
licensed under Creative Commons Attribution 3.0 Unported (CC BY 3.0),
|
||||
https://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
Changes by PhotoCraft contributors: redrawn as a 24 x 24 SVG on PhotoCraft's toolbar grid, with
|
||||
round-capped strokes in place of the original line weights, so it can be tinted and rasterized
|
||||
like the other toolbar icons.
|
||||
|
||||
The full text of the licence follows.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Creative Commons Legal Code
|
||||
|
||||
Attribution 3.0 Unported
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
|
||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
|
||||
DAMAGES RESULTING FROM ITS USE.
|
||||
|
||||
License
|
||||
|
||||
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
|
||||
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
|
||||
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
|
||||
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
|
||||
|
||||
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
|
||||
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
|
||||
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
|
||||
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
|
||||
CONDITIONS.
|
||||
|
||||
1. Definitions
|
||||
|
||||
a. "Adaptation" means a work based upon the Work, or upon the Work and
|
||||
other pre-existing works, such as a translation, adaptation,
|
||||
derivative work, arrangement of music or other alterations of a
|
||||
literary or artistic work, or phonogram or performance and includes
|
||||
cinematographic adaptations or any other form in which the Work may be
|
||||
recast, transformed, or adapted including in any form recognizably
|
||||
derived from the original, except that a work that constitutes a
|
||||
Collection will not be considered an Adaptation for the purpose of
|
||||
this License. For the avoidance of doubt, where the Work is a musical
|
||||
work, performance or phonogram, the synchronization of the Work in
|
||||
timed-relation with a moving image ("synching") will be considered an
|
||||
Adaptation for the purpose of this License.
|
||||
b. "Collection" means a collection of literary or artistic works, such as
|
||||
encyclopedias and anthologies, or performances, phonograms or
|
||||
broadcasts, or other works or subject matter other than works listed
|
||||
in Section 1(f) below, which, by reason of the selection and
|
||||
arrangement of their contents, constitute intellectual creations, in
|
||||
which the Work is included in its entirety in unmodified form along
|
||||
with one or more other contributions, each constituting separate and
|
||||
independent works in themselves, which together are assembled into a
|
||||
collective whole. A work that constitutes a Collection will not be
|
||||
considered an Adaptation (as defined above) for the purposes of this
|
||||
License.
|
||||
c. "Distribute" means to make available to the public the original and
|
||||
copies of the Work or Adaptation, as appropriate, through sale or
|
||||
other transfer of ownership.
|
||||
d. "Licensor" means the individual, individuals, entity or entities that
|
||||
offer(s) the Work under the terms of this License.
|
||||
e. "Original Author" means, in the case of a literary or artistic work,
|
||||
the individual, individuals, entity or entities who created the Work
|
||||
or if no individual or entity can be identified, the publisher; and in
|
||||
addition (i) in the case of a performance the actors, singers,
|
||||
musicians, dancers, and other persons who act, sing, deliver, declaim,
|
||||
play in, interpret or otherwise perform literary or artistic works or
|
||||
expressions of folklore; (ii) in the case of a phonogram the producer
|
||||
being the person or legal entity who first fixes the sounds of a
|
||||
performance or other sounds; and, (iii) in the case of broadcasts, the
|
||||
organization that transmits the broadcast.
|
||||
f. "Work" means the literary and/or artistic work offered under the terms
|
||||
of this License including without limitation any production in the
|
||||
literary, scientific and artistic domain, whatever may be the mode or
|
||||
form of its expression including digital form, such as a book,
|
||||
pamphlet and other writing; a lecture, address, sermon or other work
|
||||
of the same nature; a dramatic or dramatico-musical work; a
|
||||
choreographic work or entertainment in dumb show; a musical
|
||||
composition with or without words; a cinematographic work to which are
|
||||
assimilated works expressed by a process analogous to cinematography;
|
||||
a work of drawing, painting, architecture, sculpture, engraving or
|
||||
lithography; a photographic work to which are assimilated works
|
||||
expressed by a process analogous to photography; a work of applied
|
||||
art; an illustration, map, plan, sketch or three-dimensional work
|
||||
relative to geography, topography, architecture or science; a
|
||||
performance; a broadcast; a phonogram; a compilation of data to the
|
||||
extent it is protected as a copyrightable work; or a work performed by
|
||||
a variety or circus performer to the extent it is not otherwise
|
||||
considered a literary or artistic work.
|
||||
g. "You" means an individual or entity exercising rights under this
|
||||
License who has not previously violated the terms of this License with
|
||||
respect to the Work, or who has received express permission from the
|
||||
Licensor to exercise rights under this License despite a previous
|
||||
violation.
|
||||
h. "Publicly Perform" means to perform public recitations of the Work and
|
||||
to communicate to the public those public recitations, by any means or
|
||||
process, including by wire or wireless means or public digital
|
||||
performances; to make available to the public Works in such a way that
|
||||
members of the public may access these Works from a place and at a
|
||||
place individually chosen by them; to perform the Work to the public
|
||||
by any means or process and the communication to the public of the
|
||||
performances of the Work, including by public digital performance; to
|
||||
broadcast and rebroadcast the Work by any means including signs,
|
||||
sounds or images.
|
||||
i. "Reproduce" means to make copies of the Work by any means including
|
||||
without limitation by sound or visual recordings and the right of
|
||||
fixation and reproducing fixations of the Work, including storage of a
|
||||
protected performance or phonogram in digital form or other electronic
|
||||
medium.
|
||||
|
||||
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
|
||||
limit, or restrict any uses free from copyright or rights arising from
|
||||
limitations or exceptions that are provided for in connection with the
|
||||
copyright protection under copyright law or other applicable laws.
|
||||
|
||||
3. License Grant. Subject to the terms and conditions of this License,
|
||||
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
perpetual (for the duration of the applicable copyright) license to
|
||||
exercise the rights in the Work as stated below:
|
||||
|
||||
a. to Reproduce the Work, to incorporate the Work into one or more
|
||||
Collections, and to Reproduce the Work as incorporated in the
|
||||
Collections;
|
||||
b. to create and Reproduce Adaptations provided that any such Adaptation,
|
||||
including any translation in any medium, takes reasonable steps to
|
||||
clearly label, demarcate or otherwise identify that changes were made
|
||||
to the original Work. For example, a translation could be marked "The
|
||||
original work was translated from English to Spanish," or a
|
||||
modification could indicate "The original work has been modified.";
|
||||
c. to Distribute and Publicly Perform the Work including as incorporated
|
||||
in Collections; and,
|
||||
d. to Distribute and Publicly Perform Adaptations.
|
||||
e. For the avoidance of doubt:
|
||||
|
||||
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme cannot be waived, the Licensor
|
||||
reserves the exclusive right to collect such royalties for any
|
||||
exercise by You of the rights granted under this License;
|
||||
ii. Waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme can be waived, the Licensor waives the
|
||||
exclusive right to collect such royalties for any exercise by You
|
||||
of the rights granted under this License; and,
|
||||
iii. Voluntary License Schemes. The Licensor waives the right to
|
||||
collect royalties, whether individually or, in the event that the
|
||||
Licensor is a member of a collecting society that administers
|
||||
voluntary licensing schemes, via that society, from any exercise
|
||||
by You of the rights granted under this License.
|
||||
|
||||
The above rights may be exercised in all media and formats whether now
|
||||
known or hereafter devised. The above rights include the right to make
|
||||
such modifications as are technically necessary to exercise the rights in
|
||||
other media and formats. Subject to Section 8(f), all rights not expressly
|
||||
granted by Licensor are hereby reserved.
|
||||
|
||||
4. Restrictions. The license granted in Section 3 above is expressly made
|
||||
subject to and limited by the following restrictions:
|
||||
|
||||
a. You may Distribute or Publicly Perform the Work only under the terms
|
||||
of this License. You must include a copy of, or the Uniform Resource
|
||||
Identifier (URI) for, this License with every copy of the Work You
|
||||
Distribute or Publicly Perform. You may not offer or impose any terms
|
||||
on the Work that restrict the terms of this License or the ability of
|
||||
the recipient of the Work to exercise the rights granted to that
|
||||
recipient under the terms of the License. You may not sublicense the
|
||||
Work. You must keep intact all notices that refer to this License and
|
||||
to the disclaimer of warranties with every copy of the Work You
|
||||
Distribute or Publicly Perform. When You Distribute or Publicly
|
||||
Perform the Work, You may not impose any effective technological
|
||||
measures on the Work that restrict the ability of a recipient of the
|
||||
Work from You to exercise the rights granted to that recipient under
|
||||
the terms of the License. This Section 4(a) applies to the Work as
|
||||
incorporated in a Collection, but this does not require the Collection
|
||||
apart from the Work itself to be made subject to the terms of this
|
||||
License. If You create a Collection, upon notice from any Licensor You
|
||||
must, to the extent practicable, remove from the Collection any credit
|
||||
as required by Section 4(b), as requested. If You create an
|
||||
Adaptation, upon notice from any Licensor You must, to the extent
|
||||
practicable, remove from the Adaptation any credit as required by
|
||||
Section 4(b), as requested.
|
||||
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
|
||||
Collections, You must, unless a request has been made pursuant to
|
||||
Section 4(a), keep intact all copyright notices for the Work and
|
||||
provide, reasonable to the medium or means You are utilizing: (i) the
|
||||
name of the Original Author (or pseudonym, if applicable) if supplied,
|
||||
and/or if the Original Author and/or Licensor designate another party
|
||||
or parties (e.g., a sponsor institute, publishing entity, journal) for
|
||||
attribution ("Attribution Parties") in Licensor's copyright notice,
|
||||
terms of service or by other reasonable means, the name of such party
|
||||
or parties; (ii) the title of the Work if supplied; (iii) to the
|
||||
extent reasonably practicable, the URI, if any, that Licensor
|
||||
specifies to be associated with the Work, unless such URI does not
|
||||
refer to the copyright notice or licensing information for the Work;
|
||||
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
|
||||
a credit identifying the use of the Work in the Adaptation (e.g.,
|
||||
"French translation of the Work by Original Author," or "Screenplay
|
||||
based on original Work by Original Author"). The credit required by
|
||||
this Section 4 (b) may be implemented in any reasonable manner;
|
||||
provided, however, that in the case of a Adaptation or Collection, at
|
||||
a minimum such credit will appear, if a credit for all contributing
|
||||
authors of the Adaptation or Collection appears, then as part of these
|
||||
credits and in a manner at least as prominent as the credits for the
|
||||
other contributing authors. For the avoidance of doubt, You may only
|
||||
use the credit required by this Section for the purpose of attribution
|
||||
in the manner set out above and, by exercising Your rights under this
|
||||
License, You may not implicitly or explicitly assert or imply any
|
||||
connection with, sponsorship or endorsement by the Original Author,
|
||||
Licensor and/or Attribution Parties, as appropriate, of You or Your
|
||||
use of the Work, without the separate, express prior written
|
||||
permission of the Original Author, Licensor and/or Attribution
|
||||
Parties.
|
||||
c. Except as otherwise agreed in writing by the Licensor or as may be
|
||||
otherwise permitted by applicable law, if You Reproduce, Distribute or
|
||||
Publicly Perform the Work either by itself or as part of any
|
||||
Adaptations or Collections, You must not distort, mutilate, modify or
|
||||
take other derogatory action in relation to the Work which would be
|
||||
prejudicial to the Original Author's honor or reputation. Licensor
|
||||
agrees that in those jurisdictions (e.g. Japan), in which any exercise
|
||||
of the right granted in Section 3(b) of this License (the right to
|
||||
make Adaptations) would be deemed to be a distortion, mutilation,
|
||||
modification or other derogatory action prejudicial to the Original
|
||||
Author's honor and reputation, the Licensor will waive or not assert,
|
||||
as appropriate, this Section, to the fullest extent permitted by the
|
||||
applicable national law, to enable You to reasonably exercise Your
|
||||
right under Section 3(b) of this License (right to make Adaptations)
|
||||
but not otherwise.
|
||||
|
||||
5. Representations, Warranties and Disclaimer
|
||||
|
||||
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
|
||||
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
|
||||
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
|
||||
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
|
||||
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
|
||||
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
|
||||
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
|
||||
|
||||
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
|
||||
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
|
||||
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
|
||||
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
|
||||
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
7. Termination
|
||||
|
||||
a. This License and the rights granted hereunder will terminate
|
||||
automatically upon any breach by You of the terms of this License.
|
||||
Individuals or entities who have received Adaptations or Collections
|
||||
from You under this License, however, will not have their licenses
|
||||
terminated provided such individuals or entities remain in full
|
||||
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
|
||||
survive any termination of this License.
|
||||
b. Subject to the above terms and conditions, the license granted here is
|
||||
perpetual (for the duration of the applicable copyright in the Work).
|
||||
Notwithstanding the above, Licensor reserves the right to release the
|
||||
Work under different license terms or to stop distributing the Work at
|
||||
any time; provided, however that any such election will not serve to
|
||||
withdraw this License (or any other license that has been, or is
|
||||
required to be, granted under the terms of this License), and this
|
||||
License will continue in full force and effect unless terminated as
|
||||
stated above.
|
||||
|
||||
8. Miscellaneous
|
||||
|
||||
a. Each time You Distribute or Publicly Perform the Work or a Collection,
|
||||
the Licensor offers to the recipient a license to the Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
|
||||
offers to the recipient a license to the original Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
c. If any provision of this License is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this License, and without further action
|
||||
by the parties to this agreement, such provision shall be reformed to
|
||||
the minimum extent necessary to make such provision valid and
|
||||
enforceable.
|
||||
d. No term or provision of this License shall be deemed waived and no
|
||||
breach consented to unless such waiver or consent shall be in writing
|
||||
and signed by the party to be charged with such waiver or consent.
|
||||
e. This License constitutes the entire agreement between the parties with
|
||||
respect to the Work licensed here. There are no understandings,
|
||||
agreements or representations with respect to the Work not specified
|
||||
here. Licensor shall not be bound by any additional provisions that
|
||||
may appear in any communication from You. This License may not be
|
||||
modified without the mutual written agreement of the Licensor and You.
|
||||
f. The rights granted under, and the subject matter referenced, in this
|
||||
License were drafted utilizing the terminology of the Berne Convention
|
||||
for the Protection of Literary and Artistic Works (as amended on
|
||||
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
|
||||
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
|
||||
and the Universal Copyright Convention (as revised on July 24, 1971).
|
||||
These rights and subject matter take effect in the relevant
|
||||
jurisdiction in which the License terms are sought to be enforced
|
||||
according to the corresponding provisions of the implementation of
|
||||
those treaty provisions in the applicable national law. If the
|
||||
standard suite of rights granted under applicable copyright law
|
||||
includes additional rights not granted under this License, such
|
||||
additional rights are deemed to be included in the License; this
|
||||
License is not intended to restrict the license of any rights under
|
||||
applicable law.
|
||||
|
||||
|
||||
Creative Commons Notice
|
||||
|
||||
Creative Commons is not a party to this License, and makes no warranty
|
||||
whatsoever in connection with the Work. Creative Commons will not be
|
||||
liable to You or any party on any legal theory for any damages
|
||||
whatsoever, including without limitation any general, special,
|
||||
incidental or consequential damages arising in connection to this
|
||||
license. Notwithstanding the foregoing two (2) sentences, if Creative
|
||||
Commons has expressly identified itself as the Licensor hereunder, it
|
||||
shall have all rights and obligations of Licensor.
|
||||
|
||||
Except for the limited purpose of indicating to the public that the
|
||||
Work is licensed under the CCPL, Creative Commons does not authorize
|
||||
the use by either party of the trademark "Creative Commons" or any
|
||||
related trademark or logo of Creative Commons without the prior
|
||||
written consent of Creative Commons. Any permitted use will be in
|
||||
compliance with Creative Commons' then-current trademark usage
|
||||
guidelines, as may be published on its website or otherwise made
|
||||
available upon request from time to time. For the avoidance of doubt,
|
||||
this trademark restriction does not form part of this License.
|
||||
|
||||
Creative Commons may be contacted at https://creativecommons.org/.
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
|
||||
<rect x="16.5" y="16.5" width="4.5" height="4.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 410 B |
@@ -1,17 +0,0 @@
|
||||
<!-- Adapted from "Magnetic Lasso Tool" by Lil' Seal from Noun Project (CC BY 3.0): see LICENSE-noun-magnetic-lasso.txt -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 1.9H16.4A4.6 4.6 0 0 1 21 6.5V7.7A4.6 4.6 0 0 1 16.4 12.3H9V8.95H16.55A0.6 0.6 0 0 0 17.15 8.35V5.85A0.6 0.6 0 0 0 16.55 5.25H9ZM9.75 2.65H12.5V4.5H9.75ZM9.75 9.7H12.5V11.55H9.75Z" fill="currentColor" fill-rule="evenodd" stroke="none" />
|
||||
<path d="M12.4 7.5 1.8 3.6l7.7 15.3-1.8 2.8" />
|
||||
<ellipse cx="9.25" cy="16.65" rx="2.85" ry="2.2" />
|
||||
<path d="m12.1 16.2 10.1-3-1.6-1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 729 B |
@@ -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` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
|
||||
Use `photocraft-cli --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. `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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -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 on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
|
||||
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; 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,8 +22,6 @@ 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,24 +1,9 @@
|
||||
# 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. 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`.
|
||||
|
||||
## WebP
|
||||
|
||||
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
|
||||
`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.
|
||||
|
||||
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:
|
||||
@@ -34,4 +19,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 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.
|
||||
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.
|
||||
@@ -7,10 +7,7 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
|
||||
- Headless TCP refuses a successfully bound non-loopback address.
|
||||
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
|
||||
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
|
||||
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
|
||||
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
|
||||
are limited to 16, and headless/MCP batches to 256 steps.
|
||||
- Encoded request lines are limited to 1 MiB, active TCP connections 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.
|
||||
@@ -19,34 +16,22 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
|
||||
retains its existing behavior.
|
||||
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
|
||||
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
|
||||
downscaling; the 5 MiB PNG limit applies to the returned image.
|
||||
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
|
||||
- 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. The policy also covers every command a command runs on its
|
||||
own behalf (an action's steps, `file.automate.conditionalModeChange` running `image.mode.*`), so
|
||||
an allowed command can't reach a denied one.
|
||||
- `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.
|
||||
user-configured script-event paths.
|
||||
|
||||
## 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. 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.
|
||||
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.
|
||||
|
||||
### 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. A 256-bit bearer token is required before method dispatch, but authentication does not grant per-client or per-tool capabilities.
|
||||
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.
|
||||
|
||||
### 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. Request-line, connection, batch, and preview limits mitigate some cases; explicit JSON-depth, document-memory, and command-duration budgets remain absent.
|
||||
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.
|
||||
|
||||
### Supply-chain compromise
|
||||
|
||||
|
||||
@@ -1,943 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/photocraft",
|
||||
"branch": "main",
|
||||
"head": "aaa946b56df2d74d198c47e67dda35ddce0b3769",
|
||||
"head_date": "2026-10-08T01:20:47Z",
|
||||
"commits": 482,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 124,
|
||||
"commits": 201,
|
||||
"lines_added": 277845,
|
||||
"lines_deleted": 22466,
|
||||
"binary_added": 39,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T06:12:04Z",
|
||||
"last_commit": "2026-10-07T22:09:41Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 2707,
|
||||
"lines_deleted": 54,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T04:57:38Z",
|
||||
"last_commit": "2026-10-05T22:14:20Z"
|
||||
},
|
||||
{
|
||||
"login": "Multipad-cyber",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 171,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T05:02:49Z",
|
||||
"last_commit": "2026-10-06T11:43:37Z"
|
||||
},
|
||||
{
|
||||
"login": "torstfugl",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 377,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T05:03:49Z",
|
||||
"last_commit": "2026-10-04T16:21:25Z"
|
||||
},
|
||||
{
|
||||
"login": "sommerper",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 9,
|
||||
"lines_added": 865,
|
||||
"lines_deleted": 148,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T14:13:31Z",
|
||||
"last_commit": "2026-10-07T11:12:58Z"
|
||||
},
|
||||
{
|
||||
"login": "Gh0stlyKn1ght",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 3349,
|
||||
"lines_deleted": 255,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T16:31:18Z",
|
||||
"last_commit": "2026-10-05T05:02:40Z"
|
||||
},
|
||||
{
|
||||
"login": "MikeeI",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 22,
|
||||
"commits": 22,
|
||||
"lines_added": 753,
|
||||
"lines_deleted": 178,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T04:59:38Z",
|
||||
"last_commit": "2026-10-05T05:37:25Z"
|
||||
},
|
||||
{
|
||||
"login": "tiredIsa",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 7086,
|
||||
"lines_deleted": 493,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T05:01:36Z",
|
||||
"last_commit": "2026-10-07T21:59:16Z"
|
||||
},
|
||||
{
|
||||
"login": "Ni-zav",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 12,
|
||||
"commits": 12,
|
||||
"lines_added": 488,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T05:01:49Z",
|
||||
"last_commit": "2026-10-07T21:57:43Z"
|
||||
},
|
||||
{
|
||||
"login": "kntrttm",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 4228,
|
||||
"lines_deleted": 1172,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T05:02:52Z",
|
||||
"last_commit": "2026-10-05T23:40:10Z"
|
||||
},
|
||||
{
|
||||
"login": "kevin9327",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 15,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T08:14:40Z",
|
||||
"last_commit": "2026-10-05T08:14:40Z"
|
||||
},
|
||||
{
|
||||
"login": "mekanix",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 29,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T13:01:58Z",
|
||||
"last_commit": "2026-10-05T13:01:58Z"
|
||||
},
|
||||
{
|
||||
"login": "nuIIpointerexception",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 82,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T13:02:14Z",
|
||||
"last_commit": "2026-10-05T13:02:14Z"
|
||||
},
|
||||
{
|
||||
"login": "john8675309",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 89,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T13:58:45Z",
|
||||
"last_commit": "2026-10-05T13:58:45Z"
|
||||
},
|
||||
{
|
||||
"login": "CQMHV",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 9,
|
||||
"lines_added": 2841,
|
||||
"lines_deleted": 23,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T22:13:51Z",
|
||||
"last_commit": "2026-10-07T20:43:03Z"
|
||||
},
|
||||
{
|
||||
"login": "kuishou68",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 6,
|
||||
"lines_deleted": 1931,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:43:03Z",
|
||||
"last_commit": "2026-10-06T11:43:03Z"
|
||||
},
|
||||
{
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 199,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:44:08Z",
|
||||
"last_commit": "2026-10-07T22:53:56Z"
|
||||
},
|
||||
{
|
||||
"login": "bommerts",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 30,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T14:46:41Z",
|
||||
"last_commit": "2026-10-06T14:46:41Z"
|
||||
},
|
||||
{
|
||||
"login": "leonaci",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 202,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:27:54Z",
|
||||
"last_commit": "2026-10-06T15:27:54Z"
|
||||
},
|
||||
{
|
||||
"login": "juansebsol",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 800,
|
||||
"lines_deleted": 118,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:28:09Z",
|
||||
"last_commit": "2026-10-07T12:40:00Z"
|
||||
},
|
||||
{
|
||||
"login": "WildfootW",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2027,
|
||||
"lines_deleted": 17,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:28:23Z",
|
||||
"last_commit": "2026-10-06T15:28:23Z"
|
||||
},
|
||||
{
|
||||
"login": "Mawfyy",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 2213,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:28:37Z",
|
||||
"last_commit": "2026-10-07T20:12:00Z"
|
||||
},
|
||||
{
|
||||
"login": "petrleocompel",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2025,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T15:29:44Z",
|
||||
"last_commit": "2026-10-06T15:29:44Z"
|
||||
},
|
||||
{
|
||||
"login": "bflatastic",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 35,
|
||||
"commits": 35,
|
||||
"lines_added": 6009,
|
||||
"lines_deleted": 1253,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T00:59:14Z",
|
||||
"last_commit": "2026-10-08T01:16:38Z"
|
||||
},
|
||||
{
|
||||
"login": "Pedroj-64",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 141,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:28:01Z",
|
||||
"last_commit": "2026-10-07T03:43:31Z"
|
||||
},
|
||||
{
|
||||
"login": "floflo777",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 163,
|
||||
"lines_deleted": 31,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:03Z",
|
||||
"last_commit": "2026-10-07T11:05:38Z"
|
||||
},
|
||||
{
|
||||
"login": "HEOJUNFO",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 55,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:17Z",
|
||||
"last_commit": "2026-10-07T03:34:30Z"
|
||||
},
|
||||
{
|
||||
"login": "Ar-maan05",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 840,
|
||||
"lines_deleted": 111,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:44Z",
|
||||
"last_commit": "2026-10-07T20:30:52Z"
|
||||
},
|
||||
{
|
||||
"login": "agorevski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 30,
|
||||
"commits": 30,
|
||||
"lines_added": 3353,
|
||||
"lines_deleted": 404,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:59Z",
|
||||
"last_commit": "2026-10-07T21:14:03Z"
|
||||
},
|
||||
{
|
||||
"login": "naumanAhmed3",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 14,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:38:32Z",
|
||||
"last_commit": "2026-10-07T03:38:32Z"
|
||||
},
|
||||
{
|
||||
"login": "ljkx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 7478,
|
||||
"lines_deleted": 178,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:43:18Z",
|
||||
"last_commit": "2026-10-08T01:12:38Z"
|
||||
},
|
||||
{
|
||||
"login": "carterwsmith",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 1213,
|
||||
"lines_deleted": 152,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T04:32:23Z",
|
||||
"last_commit": "2026-10-07T22:57:50Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 4,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T04:32:34Z",
|
||||
"last_commit": "2026-10-07T04:32:34Z"
|
||||
},
|
||||
{
|
||||
"login": "neversaynv3r",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 89,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T04:40:40Z",
|
||||
"last_commit": "2026-10-07T04:40:40Z"
|
||||
},
|
||||
{
|
||||
"login": "ianswope",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 82,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T09:26:01Z",
|
||||
"last_commit": "2026-10-07T19:59:51Z"
|
||||
},
|
||||
{
|
||||
"login": "TheSeanLavery",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 2205,
|
||||
"lines_deleted": 66,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:11:06Z",
|
||||
"last_commit": "2026-10-07T11:45:26Z"
|
||||
},
|
||||
{
|
||||
"login": "mnmlyw",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 330,
|
||||
"lines_deleted": 41,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:12:13Z",
|
||||
"last_commit": "2026-10-08T01:20:47Z"
|
||||
},
|
||||
{
|
||||
"login": "rosematcha",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 12,
|
||||
"commits": 12,
|
||||
"lines_added": 1700,
|
||||
"lines_deleted": 202,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:49:53Z",
|
||||
"last_commit": "2026-10-08T00:18:27Z"
|
||||
},
|
||||
{
|
||||
"login": "jason4short",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 1653,
|
||||
"lines_deleted": 101,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T10:58:01Z",
|
||||
"last_commit": "2026-10-07T22:02:34Z"
|
||||
},
|
||||
{
|
||||
"login": "siraxe",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 276,
|
||||
"lines_deleted": 18,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:03:49Z",
|
||||
"last_commit": "2026-10-07T11:03:49Z"
|
||||
},
|
||||
{
|
||||
"login": "arpitkjaiswal",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 19,
|
||||
"lines_deleted": 19,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:06:21Z",
|
||||
"last_commit": "2026-10-07T11:06:21Z"
|
||||
},
|
||||
{
|
||||
"login": "EthanSuquet",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 405,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:40:10Z",
|
||||
"last_commit": "2026-10-07T20:50:09Z"
|
||||
},
|
||||
{
|
||||
"login": "alexanderalber",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 1809,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:42:30Z",
|
||||
"last_commit": "2026-10-07T22:59:39Z"
|
||||
},
|
||||
{
|
||||
"login": "flyyangX",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 242,
|
||||
"lines_deleted": 53,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:46:52Z",
|
||||
"last_commit": "2026-10-07T11:46:52Z"
|
||||
},
|
||||
{
|
||||
"login": "leblanc-simon",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2527,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:49:30Z",
|
||||
"last_commit": "2026-10-07T11:49:30Z"
|
||||
},
|
||||
{
|
||||
"login": "trokute",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2484,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:52:51Z",
|
||||
"last_commit": "2026-10-07T11:52:51Z"
|
||||
},
|
||||
{
|
||||
"login": "ha1des",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1328,
|
||||
"lines_deleted": 161,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:02:58Z",
|
||||
"last_commit": "2026-10-07T13:02:58Z"
|
||||
},
|
||||
{
|
||||
"login": "neko233-com",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 2719,
|
||||
"lines_deleted": 73,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:06:33Z",
|
||||
"last_commit": "2026-10-07T20:52:55Z"
|
||||
},
|
||||
{
|
||||
"login": "Sirsyorrz",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 61,
|
||||
"lines_deleted": 7,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:11:35Z",
|
||||
"last_commit": "2026-10-07T13:11:35Z"
|
||||
},
|
||||
{
|
||||
"login": "Elvis33LE",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 3929,
|
||||
"lines_deleted": 91,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:11:45Z",
|
||||
"last_commit": "2026-10-08T01:10:38Z"
|
||||
},
|
||||
{
|
||||
"login": "cristianmoroaica",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:12:11Z",
|
||||
"last_commit": "2026-10-07T13:12:11Z"
|
||||
},
|
||||
{
|
||||
"login": "Webificio",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 232,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T13:12:44Z",
|
||||
"last_commit": "2026-10-07T13:12:44Z"
|
||||
},
|
||||
{
|
||||
"login": "mvanhorn",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 2710,
|
||||
"lines_deleted": 168,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:09:46Z",
|
||||
"last_commit": "2026-10-07T21:12:52Z"
|
||||
},
|
||||
{
|
||||
"login": "gangldiego",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2592,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:11:38Z",
|
||||
"last_commit": "2026-10-07T20:11:38Z"
|
||||
},
|
||||
{
|
||||
"login": "kijoong-kim",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1337,
|
||||
"lines_deleted": 539,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:32:47Z",
|
||||
"last_commit": "2026-10-07T20:32:47Z"
|
||||
},
|
||||
{
|
||||
"login": "Cyanoxide",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 9,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:34:38Z",
|
||||
"last_commit": "2026-10-07T20:34:38Z"
|
||||
},
|
||||
{
|
||||
"login": "tanish2k09",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 10,
|
||||
"lines_added": 389,
|
||||
"lines_deleted": 94,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:39:39Z",
|
||||
"last_commit": "2026-10-07T23:57:32Z"
|
||||
},
|
||||
{
|
||||
"login": "jerrykimo",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 442,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:57:21Z",
|
||||
"last_commit": "2026-10-07T20:57:21Z"
|
||||
},
|
||||
{
|
||||
"login": "bfenty",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 10,
|
||||
"lines_added": 490,
|
||||
"lines_deleted": 21,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T21:45:54Z",
|
||||
"last_commit": "2026-10-08T01:07:50Z"
|
||||
},
|
||||
{
|
||||
"login": "Luna161",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 89,
|
||||
"lines_deleted": 15,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:00:59Z",
|
||||
"last_commit": "2026-10-08T01:04:05Z"
|
||||
},
|
||||
{
|
||||
"login": "abc2cb",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1197,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:40:24Z",
|
||||
"last_commit": "2026-10-07T22:40:24Z"
|
||||
},
|
||||
{
|
||||
"login": "remidesbois1",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1894,
|
||||
"lines_deleted": 400,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:05:15Z",
|
||||
"last_commit": "2026-10-07T23:05:15Z"
|
||||
},
|
||||
{
|
||||
"login": "giallorosso84",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2188,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:13:01Z",
|
||||
"last_commit": "2026-10-07T23:13:01Z"
|
||||
},
|
||||
{
|
||||
"login": "louwers",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2528,
|
||||
"lines_deleted": 21,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:42:45Z",
|
||||
"last_commit": "2026-10-07T23:42:45Z"
|
||||
},
|
||||
{
|
||||
"login": "simonwjackson",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 3,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T00:55:27Z",
|
||||
"last_commit": "2026-10-08T00:55:27Z"
|
||||
},
|
||||
{
|
||||
"login": "ajsongpilot",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 42,
|
||||
"lines_deleted": 16,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T01:09:04Z",
|
||||
"last_commit": "2026-10-08T01:09:04Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 228,
|
||||
"lines_added": 153184,
|
||||
"lines_deleted": 22216
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 45,
|
||||
"lines_added": 3896,
|
||||
"lines_deleted": 278
|
||||
},
|
||||
{
|
||||
"id": "github/copilot",
|
||||
"company": "GitHub",
|
||||
"model": "Copilot",
|
||||
"version": "",
|
||||
"commits": 6,
|
||||
"lines_added": 1094,
|
||||
"lines_deleted": 61
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-mythos/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Mythos",
|
||||
"version": "5.1",
|
||||
"commits": 2,
|
||||
"lines_added": 6195,
|
||||
"lines_deleted": 100
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 2,
|
||||
"lines_added": 4161,
|
||||
"lines_deleted": 1171
|
||||
},
|
||||
{
|
||||
"id": "alibaba/qwen-flash/3.8",
|
||||
"company": "Alibaba",
|
||||
"model": "Qwen Flash",
|
||||
"version": "3.8",
|
||||
"commits": 1,
|
||||
"lines_added": 25,
|
||||
"lines_deleted": 5
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-haiku/4.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Haiku",
|
||||
"version": "4.5",
|
||||
"commits": 1,
|
||||
"lines_added": 134,
|
||||
"lines_deleted": 12
|
||||
},
|
||||
{
|
||||
"id": "cognition/devin",
|
||||
"company": "Cognition",
|
||||
"model": "Devin",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 44,
|
||||
"lines_deleted": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer, as a document layer filling the
|
||||
//! canvas is filtered (edges repeated at the canvas edge).
|
||||
//! `cargo run --release -p photocraft-algo --example bench_gaussian [-- <size> <radius>]`
|
||||
use photocraft_algo::{FilterParams, apply_in, output_area};
|
||||
//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_gaussian`
|
||||
use photocraft_algo::{FilterParams, apply, output_area};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
@@ -16,13 +15,8 @@ fn main() {
|
||||
s.write_interleaved(r, &bytes);
|
||||
let p = FilterParams::GaussianBlur { radius };
|
||||
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
|
||||
// Best of a few runs (the first also pays for page faults).
|
||||
let mut dt = std::time::Duration::MAX;
|
||||
let mut out = s.clone();
|
||||
for _ in 0..5 {
|
||||
let t = std::time::Instant::now();
|
||||
out = apply_in(&s, &p, area, r, None, r);
|
||||
dt = dt.min(t.elapsed());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let out = apply(&s, &p, area, r, None);
|
||||
let dt = t.elapsed();
|
||||
println!("gaussian radius {radius} on {n}x{n} RGBA8: {:.2?} ({} tiles)", dt, out.tile_count());
|
||||
}
|
||||
@@ -147,12 +147,9 @@ impl GalleryFilter {
|
||||
out
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Whether the filter reads the foreground / background colours.
|
||||
pub fn uses_colours(self) -> bool {
|
||||
matches!(self, Self::ColoredPencil | Self::NeonGlow) || self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
|
||||
self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,26 +264,6 @@ fn strokes(x: i32, y: i32, d: (i32, i32), len: f32, wid: f32, seed: u32) -> f32
|
||||
vnoise(u / len.max(0.5), v / wid.max(0.3), seed)
|
||||
}
|
||||
|
||||
/// Repaints in discrete strokes `len` px long and `wid` px wide along `d`: every stroke takes the
|
||||
/// colour of `src` at its middle, and neighbouring strokes start at staggered points, so the
|
||||
/// image reads as separate strokes rather than a smear. Also returns where the pixel lies along
|
||||
/// its stroke (0 at the start, 1 at the end). Reads up to `len / 2 + 1` px away.
|
||||
fn paint_stroke(src: &Win, x: i32, y: i32, d: (i32, i32), len: f32, wid: f32, seed: u32) -> (Px, f32) {
|
||||
let (dx, dy) = (d.0 as f32, d.1 as f32);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-6);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
let (len, wid) = (len.max(1.0), wid.max(0.5));
|
||||
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let u = fx * ux + fy * uy;
|
||||
let v = -fx * uy + fy * ux;
|
||||
let lane = (v / wid).floor() as i32;
|
||||
let off = hash01(lane, 0, 0, seed) * len;
|
||||
let t = ((u + off) / len).rem_euclid(1.0);
|
||||
// Back to the stroke's middle along the direction.
|
||||
let du = (0.5 - t) * len;
|
||||
(src.sample(fx + du * ux, fy + du * uy), t)
|
||||
}
|
||||
|
||||
/// Unit vector toward the light in image coordinates (y down).
|
||||
fn light_dir(name: &str) -> (f32, f32) {
|
||||
let s = std::f32::consts::FRAC_1_SQRT_2;
|
||||
@@ -353,11 +333,6 @@ fn weave(x: f32, y: f32, p: f32, noise: f32) -> f32 {
|
||||
|
||||
/// Texturizer core: relief-shades the window with a procedural texture.
|
||||
fn texturize(win: &mut Win, kind: &str, scaling: f32, relief: f32, light: &str, invert: bool) {
|
||||
texturize_by(win, kind, scaling, relief, light, invert, |_| 1.0);
|
||||
}
|
||||
|
||||
/// [`texturize`] with the relief scaled per pixel by `weight` of its colour (0–1).
|
||||
fn texturize_by(win: &mut Win, kind: &str, scaling: f32, relief: f32, light: &str, invert: bool, weight: impl Fn(&Px) -> f32) {
|
||||
if relief <= 0.0 {
|
||||
return;
|
||||
}
|
||||
@@ -370,7 +345,7 @@ fn texturize_by(win: &mut Win, kind: &str, scaling: f32, relief: f32, light: &st
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
let gx = (texture(kind, fx + 1.0, fy, s) - texture(kind, fx - 1.0, fy, s)) * 0.5 * sign;
|
||||
let gy = (texture(kind, fx, fy + 1.0, s) - texture(kind, fx, fy - 1.0, s)) * 0.5 * sign;
|
||||
let f = 1.0 + (shade(gx, gy, l, k).clamp(0.0, 2.0) - 1.0) * weight(&win.c[i]);
|
||||
let f = shade(gx, gy, l, k).clamp(0.0, 2.0);
|
||||
win.c[i] = map(win.c[i], |v| v * f);
|
||||
}
|
||||
}
|
||||
@@ -412,7 +387,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
F::Cutout => gs(cutout_sigma(e)),
|
||||
F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2,
|
||||
F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0,
|
||||
F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3,
|
||||
F::RoughPastels => (g("strokeLength") / 2.0) as i32 + 2,
|
||||
F::NeonGlow => gs(g("glowSize").abs() * 0.6 + 0.5),
|
||||
F::PaintDaubs => kuwahara_reach(daubs_radius(e)) + gs(1.5) + 1,
|
||||
F::PaletteKnife => kuwahara_reach((g("strokeSize") / 3.0).max(1.0) as usize) + gs(g("softness") * 0.5),
|
||||
@@ -423,7 +398,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
F::Underpainting => gs(0.5 + g("brushSize") * 0.4),
|
||||
F::Watercolor => kuwahara_reach(watercolor_radius(e)) + 2,
|
||||
F::AccentedEdges => gs(0.4 + g("smoothness") * 0.3) + gs(g("edgeWidth") * 0.5) + 2,
|
||||
F::AngledStrokes => gs(0.8) + (g("strokeLength").max(3.0) / 2.0) as i32 + gs(1.0) + 3,
|
||||
F::AngledStrokes => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
|
||||
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
|
||||
F::DarkStrokes => 9,
|
||||
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
|
||||
@@ -636,23 +611,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
}
|
||||
}
|
||||
F::RoughPastels => {
|
||||
// Chalk strokes up to the right; Stroke Detail keeps more of the image inside them.
|
||||
let len = g("strokeLength");
|
||||
let det = (g("strokeDetail") - 1.0) / 19.0;
|
||||
if len >= 1.0 {
|
||||
let mut soft = win.c.clone();
|
||||
blur3(&mut soft, w, h, 0.6 + (1.0 - det) * 1.2);
|
||||
let src = Win { r: win.r, w, h, c: soft };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let (c, t) = paint_stroke(&src, x, y, (1, -1), 2.0 + len, 1.5, 51);
|
||||
// A chalk stroke is a little lighter in its middle, grainy along its length.
|
||||
let n = (strokes(x, y, (1, -1), 2.0 + len * 0.5, 0.8, 52) - 0.5) * 0.12 + (0.5 - (t - 0.5).abs()) * 0.06;
|
||||
win.c[i] = map(mix(c, win.c[i], det * 0.6), |v| v + n);
|
||||
}
|
||||
let det = g("strokeDetail") / 20.0;
|
||||
let orig = win.c.clone();
|
||||
line_blur3(&mut win.c, w, h, (1, -1), (len / 2.0) as usize);
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let n = strokes(x, y, (1, -1), 3.0 + len * 0.8, 1.1, 51) - 0.5;
|
||||
let p = mix(win.c[i], orig[i], det * 0.5);
|
||||
win.c[i] = map(p, |v| v + n * 0.3);
|
||||
}
|
||||
// Bright areas are thick chalk; in dark ones it's scraped off to show the texture.
|
||||
texturize_by(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert"), |p| 0.15 + 0.85 * (1.0 - clamp01(lum(p))));
|
||||
texturize(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert"));
|
||||
}
|
||||
F::SmudgeStick => {
|
||||
line_blur3(&mut win.c, w, h, (1, 1), (g("strokeLength") * 1.5) as usize);
|
||||
@@ -708,41 +677,33 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) };
|
||||
}
|
||||
}
|
||||
F::AngledStrokes => {
|
||||
// Light areas are painted in strokes going one way, dark areas the other way;
|
||||
// Direction Balance moves the split (0: all down-right, 100: all up-right).
|
||||
let len = g("strokeLength").max(3.0);
|
||||
let bal = g("directionBalance") / 100.0;
|
||||
let mut soft = win.c.clone();
|
||||
blur3(&mut soft, w, h, 0.8);
|
||||
let l: Vec<f32> = soft.iter().map(lum).collect();
|
||||
let src = Win { r: win.r, w, h, c: soft };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let k = smoothstep(1.0 - bal - 0.05, 1.0 - bal + 0.05, l[i]);
|
||||
let (down, _) = paint_stroke(&src, x, y, (1, 1), len, 2.0, 83);
|
||||
let (up, _) = paint_stroke(&src, x, y, (1, -1), len, 2.0, 84);
|
||||
let n = (strokes(x, y, (1, 1), len * 0.5, 0.7, 85) - 0.5) * (1.0 - k) + (strokes(x, y, (1, -1), len * 0.5, 0.7, 86) - 0.5) * k;
|
||||
win.c[i] = map(mix(down, up, k), |v| v + n * 0.1);
|
||||
}
|
||||
unsharp(win, 1.0, 0.2 + g("sharpness") / 10.0 * 2.3);
|
||||
}
|
||||
F::Crosshatch => {
|
||||
F::AngledStrokes | F::Crosshatch => {
|
||||
let len = g("strokeLength");
|
||||
let r = (len / 3.0) as usize;
|
||||
let st = g("strength");
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let n = (hash01(x, y, 0, 81) - 0.5) * 0.12 * st;
|
||||
win.c[i] = map(win.c[i], |v| v + n);
|
||||
if e.filter == F::Crosshatch {
|
||||
let st = g("strength");
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let n = (hash01(x, y, 0, 81) - 0.5) * 0.12 * st;
|
||||
win.c[i] = map(win.c[i], |v| v + n);
|
||||
}
|
||||
}
|
||||
let l = win.lum();
|
||||
let mut a = win.c.clone();
|
||||
line_blur3(&mut a, w, h, (1, 1), r);
|
||||
line_blur3(&mut win.c, w, h, (1, -1), r);
|
||||
for i in 0..win.c.len() {
|
||||
win.c[i] = mix(a[i], win.c[i], 0.5);
|
||||
if e.filter == F::AngledStrokes {
|
||||
let bal = g("directionBalance") / 100.0;
|
||||
for i in 0..win.c.len() {
|
||||
win.c[i] = mix(a[i], win.c[i], smoothstep(1.0 - bal - 0.15, 1.0 - bal + 0.15, l[i]));
|
||||
}
|
||||
unsharp(win, 1.0, g("sharpness") / 10.0 * 1.5);
|
||||
} else {
|
||||
for i in 0..win.c.len() {
|
||||
win.c[i] = mix(a[i], win.c[i], 0.5);
|
||||
}
|
||||
unsharp(win, 1.0, g("sharpness") / 20.0 * 3.0);
|
||||
}
|
||||
unsharp(win, 1.0, g("sharpness") / 20.0 * 3.0);
|
||||
}
|
||||
F::DarkStrokes => {
|
||||
let (bal, bi, wi) = (g("balance") / 10.0, g("blackIntensity") / 10.0, g("whiteIntensity") / 10.0);
|
||||
@@ -1212,33 +1173,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rough_pastels_and_angled_strokes_respond_to_every_slider() {
|
||||
// #879: these smeared or drowned the image in texture, and some sliders did nothing.
|
||||
use crate::GalleryFilter as G;
|
||||
let b = Rect::new(0, 0, 56, 40);
|
||||
let s = pattern(SampleType::F32);
|
||||
let run = |e: &GalleryEffect, tile: i32| {
|
||||
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
|
||||
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
|
||||
};
|
||||
for f in [G::RoughPastels, G::AngledStrokes] {
|
||||
let base = run(&GalleryEffect::new(f), 256);
|
||||
for prm in f.params() {
|
||||
let crate::GalleryParamKind::Range { min, max, .. } = prm.kind else { continue };
|
||||
for v in [min, max] {
|
||||
let mut e = GalleryEffect::new(f);
|
||||
e.set(prm.key, v);
|
||||
let a = run(&e, 256);
|
||||
assert_ne!(a, base, "{} {} = {v} changed nothing", f.key(), prm.key);
|
||||
let t = run(&e, 16);
|
||||
let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
|
||||
assert!(worst < 1e-3, "{} {} = {v} differs across tilings by {worst}", f.key(), prm.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parse_and_clamp() {
|
||||
for f in crate::GalleryFilter::ALL {
|
||||
|
||||
+119
-460
@@ -1,11 +1,9 @@
|
||||
//! Blurs: Gaussian, box, motion, radial, surface.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Interrupt;
|
||||
|
||||
use crate::image::{Edge, Image, premultiply, unpremultiply};
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
use crate::{Ctx, FilterParams, RadialMethod};
|
||||
use crate::{Ctx, RadialMethod};
|
||||
|
||||
/// Normalized Gaussian kernel with standard deviation `sigma` (radius 3σ).
|
||||
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
@@ -71,13 +69,11 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
|
||||
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
|
||||
match box_widths(&FilterParams::GaussianBlur { radius }) {
|
||||
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
None => {
|
||||
let k = gaussian_kernel(radius);
|
||||
conv_sep(src, out, &k, &k, ctx.alpha)
|
||||
}
|
||||
if radius <= 4.0 {
|
||||
let k = gaussian_kernel(radius);
|
||||
return conv_sep(src, out, &k, &k, ctx.alpha);
|
||||
}
|
||||
gaussian_boxes(src, out, ctx.alpha, radius)
|
||||
}
|
||||
|
||||
/// Box widths (odd) whose `n`-fold convolution has standard deviation `sigma`.
|
||||
@@ -95,219 +91,134 @@ fn boxes_for_gauss(sigma: f32, n: usize) -> Vec<usize> {
|
||||
(0..n).map(|i| if i < m { wl as usize } else { wu as usize }).collect()
|
||||
}
|
||||
|
||||
/// The box widths (odd) a blur runs as running-sum passes along each axis, or `None` when it uses a
|
||||
/// direct kernel (small radii, where the kernel is quicker).
|
||||
pub(crate) fn box_widths(params: &FilterParams) -> Option<Vec<usize>> {
|
||||
match *params {
|
||||
FilterParams::GaussianBlur { radius } if radius > 4.0 => Some(boxes_for_gauss(radius, 3)),
|
||||
FilterParams::BoxBlur { radius } => {
|
||||
let r = radius.max(0.0).round() as usize;
|
||||
(r > 4).then(|| vec![2 * r + 1])
|
||||
}
|
||||
_ => None,
|
||||
/// Running-sum box blur of every row of an interleaved `w × h × n` buffer (edges clamped).
|
||||
fn box_rows(buf: &mut [f32], w: usize, n: usize, r: usize) {
|
||||
if r == 0 || w == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scratch buffers of one worker for [`box_blur`].
|
||||
#[derive(Default)]
|
||||
struct Scratch {
|
||||
line: Vec<f32>,
|
||||
next: Vec<f32>,
|
||||
acc: Vec<f32>,
|
||||
}
|
||||
|
||||
/// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's
|
||||
/// channels, or a strip of a row): element `i` of `dst` is the mean of elements `i ..= i + 2r` of
|
||||
/// `src`, which is `2r` elements longer.
|
||||
fn box_pass(src: &[f32], dst: &mut [f32], w: usize, r: usize, acc: &mut Vec<f32>) {
|
||||
// Pixels are short elements: fixed sizes let the compiler keep the sums in registers.
|
||||
match w {
|
||||
1 => box_pass_px::<1>(src, dst, r),
|
||||
2 => box_pass_px::<2>(src, dst, r),
|
||||
3 => box_pass_px::<3>(src, dst, r),
|
||||
4 => box_pass_px::<4>(src, dst, r),
|
||||
5 => box_pass_px::<5>(src, dst, r),
|
||||
0 => {}
|
||||
_ => {
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
acc.clear();
|
||||
acc.resize(w, 0.0);
|
||||
for e in src.chunks_exact(w).take(2 * r + 1) {
|
||||
for (a, s) in acc.iter_mut().zip(e) {
|
||||
*a += s;
|
||||
}
|
||||
}
|
||||
for (i, d) in dst.chunks_exact_mut(w).enumerate() {
|
||||
for (d, a) in d.iter_mut().zip(acc.iter()) {
|
||||
*d = a * norm;
|
||||
}
|
||||
let (add, sub) = ((i + 2 * r + 1) * w, i * w);
|
||||
if let (Some(add), Some(sub)) = (src.get(add..add + w), src.get(sub..sub + w)) {
|
||||
for ((a, add), sub) in acc.iter_mut().zip(add).zip(sub) {
|
||||
*a += add - sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`box_pass`] for elements of `N` floats.
|
||||
fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
let (src, dst) = (src.as_chunks::<N>().0, dst.as_chunks_mut::<N>().0);
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
let mut acc = [0.0f32; N];
|
||||
for e in src.iter().take(2 * r + 1) {
|
||||
for c in 0..N {
|
||||
acc[c] += e[c];
|
||||
let blur_row = |row: &mut [f32]| {
|
||||
// The row with clamped edges materialised (r + 1 pixels each side), so the running sum
|
||||
// reads without bounds clamping, all channels per step.
|
||||
let pad = r + 1;
|
||||
let mut src = Vec::with_capacity((w + 2 * pad) * n);
|
||||
for _ in 0..pad {
|
||||
src.extend_from_slice(&row[..n]);
|
||||
}
|
||||
}
|
||||
for (i, d) in dst.iter_mut().enumerate() {
|
||||
for c in 0..N {
|
||||
d[c] = acc[c] * norm;
|
||||
src.extend_from_slice(row);
|
||||
for _ in 0..pad {
|
||||
src.extend_from_slice(&row[(w - 1) * n..w * n]);
|
||||
}
|
||||
if let (Some(add), Some(sub)) = (src.get(i + 2 * r + 1), src.get(i)) {
|
||||
for c in 0..N {
|
||||
acc[c] += add[c] - sub[c];
|
||||
let mut acc = [0.0f32; 8];
|
||||
let acc = &mut acc[..n.min(8)];
|
||||
if n > 8 {
|
||||
// Unusual channel counts: per channel.
|
||||
for c in 0..n {
|
||||
let mut a: f32 = (0..=2 * r).map(|i| src[(pad - r + i) * n + c]).sum();
|
||||
for x in 0..w {
|
||||
row[x * n + c] = a * norm;
|
||||
a += src[(x + pad + r + 1) * n + c] - src[(x + pad - r) * n + c];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..=2 * r {
|
||||
let o = (pad - r + i) * n;
|
||||
for c in 0..n {
|
||||
acc[c] += src[o + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many
|
||||
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`.
|
||||
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) {
|
||||
let Scratch { line, next, acc } = s;
|
||||
let Some(len) = dst.len().checked_div(w) else { return };
|
||||
let mut tail: usize = radii.iter().sum();
|
||||
for (k, &r) in radii.iter().enumerate() {
|
||||
tail -= r;
|
||||
if k + 1 == radii.len() {
|
||||
box_pass(line, dst, w, r, acc);
|
||||
} else {
|
||||
next.resize((len + 2 * tail) * w, 0.0);
|
||||
box_pass(line, next, w, r, acc);
|
||||
std::mem::swap(line, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows of the horizontal passes handed to one worker (they share its scratch buffers).
|
||||
const ROWS_PER_TASK: usize = 8;
|
||||
/// Width in pixels of the column strips the vertical passes work on (a divisor of the tile size).
|
||||
pub(crate) const STRIP: i32 = 64;
|
||||
|
||||
/// Samples of a column strip of an output area.
|
||||
pub(crate) type Strip = (Rect, Vec<f32>);
|
||||
|
||||
/// Box blurs of the given (odd) widths, one after another along each axis, of the `n`-channel
|
||||
/// pixels of `src` extended beyond it by repeating its edge pixels (alpha-weighted when `alpha`).
|
||||
/// `read(row, buf)` fills `buf` with the samples of a one-pixel-high rectangle inside `src`.
|
||||
///
|
||||
/// Running sums make the cost independent of the widths, and only `src`'s own rows and the output
|
||||
/// columns are blurred: the repeated edges are never read or blurred as 2-D margins, so a huge
|
||||
/// radius costs about what a small one does. Returns the straight results for `out` as column
|
||||
/// strips (`None` when `ctl` was cancelled).
|
||||
pub(crate) fn box_blur(
|
||||
src: Rect,
|
||||
read: &(dyn Fn(Rect, &mut Vec<f32>) + Sync),
|
||||
out: Rect,
|
||||
n: usize,
|
||||
alpha: bool,
|
||||
boxes: &[usize],
|
||||
ctl: &Interrupt,
|
||||
) -> Option<Vec<Strip>> {
|
||||
let radii: Vec<usize> = boxes.iter().map(|w| w / 2).collect();
|
||||
let reach = i32::try_from(radii.iter().sum::<usize>()).unwrap_or(i32::MAX);
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let used = src.intersect(&out.inflate(reach));
|
||||
if used.is_empty() || n == 0 {
|
||||
// Nothing to read: transparent, as `Image::get` reads outside an image.
|
||||
return Some(strips(out).map(|t| (t, vec![0.0; t.width() as usize * oh * n])).collect());
|
||||
}
|
||||
// Horizontal passes over `used`'s rows only (the repeated rows above and below it would just
|
||||
// repeat their results), for the output columns. Each row is extended to the passes' reach by
|
||||
// repeating its end pixels.
|
||||
let mut h = vec![0.0f32; used.height() as usize * ow * n];
|
||||
let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize);
|
||||
par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| {
|
||||
let (mut row, mut s) = (Vec::new(), Scratch::default());
|
||||
for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
for x in 0..w {
|
||||
let (add, sub) = ((x + pad + r + 1) * n, (x + pad - r) * n);
|
||||
for c in 0..n {
|
||||
row[x * n + c] = acc[c] * norm;
|
||||
acc[c] += src[add + c] - src[sub + c];
|
||||
}
|
||||
let y = used.y0 + (task * ROWS_PER_TASK + k) as i32;
|
||||
read(Rect::new(used.x0, y, used.x1, y + 1), &mut row);
|
||||
premultiply(&mut row, n, alpha);
|
||||
let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return };
|
||||
s.line.clear();
|
||||
(0..left).for_each(|_| s.line.extend_from_slice(first));
|
||||
s.line.extend_from_slice(&row);
|
||||
(0..right).for_each(|_| s.line.extend_from_slice(last));
|
||||
line_passes(dst, n, &radii, &mut s);
|
||||
}
|
||||
});
|
||||
// Vertical passes, strip by strip, the same way down the columns.
|
||||
let (top, bottom) = ((used.y0 - out.y0.saturating_sub(reach)) as usize, (out.y1.saturating_add(reach) - used.y1) as usize);
|
||||
let (rows, stride) = (used.height() as usize, ow * n);
|
||||
let strips: Vec<Rect> = strips(out).collect();
|
||||
let done = par_map(strips.len(), |k| {
|
||||
let t = strips[k];
|
||||
if ctl.cancelled() {
|
||||
return (t, Vec::new());
|
||||
}
|
||||
let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
|
||||
let row = |y: usize| &h[y * stride + sx..y * stride + sx + w];
|
||||
let mut s = Scratch::default();
|
||||
(0..top).for_each(|_| s.line.extend_from_slice(row(0)));
|
||||
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
|
||||
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
|
||||
let mut res = vec![0.0f32; oh * w];
|
||||
line_passes(&mut res, w, &radii, &mut s);
|
||||
unpremultiply(&mut res, n, alpha);
|
||||
(t, res)
|
||||
});
|
||||
(!ctl.cancelled()).then_some(done)
|
||||
}
|
||||
|
||||
/// Column strips of `out` at multiples of [`STRIP`], so each lies inside one tile column.
|
||||
fn strips(out: Rect) -> impl Iterator<Item = Rect> {
|
||||
(out.x0.div_euclid(STRIP) * STRIP..out.x1)
|
||||
.step_by(STRIP as usize)
|
||||
.map(move |x| Rect::new(x.max(out.x0), out.y0, x.saturating_add(STRIP).min(out.x1), out.y1))
|
||||
}
|
||||
const _: () = assert!(photocraft_geom::TILE_SIZE % STRIP == 0);
|
||||
|
||||
/// [`box_blur`] of an image (edges repeated) into one interleaved buffer for `out`.
|
||||
fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let read = |r: Rect, buf: &mut Vec<f32>| {
|
||||
buf.clear();
|
||||
buf.extend_from_slice(src.row(r));
|
||||
};
|
||||
let ow = out.width() as usize * n;
|
||||
let mut res = vec![0.0f32; ow * out.height() as usize];
|
||||
if res.is_empty() {
|
||||
return res;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
buf.par_chunks_mut(w * n).for_each(blur_row);
|
||||
}
|
||||
for (t, data) in box_blur(src.rect, &read, out, n, alpha, boxes, &Interrupt::NONE).unwrap_or_default() {
|
||||
let (x0, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
|
||||
for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) {
|
||||
dst[x0..x0 + w].copy_from_slice(s);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
buf.chunks_mut(w * n).for_each(blur_row);
|
||||
}
|
||||
|
||||
fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; buf.len()];
|
||||
if w == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
const B: usize = 32; // cache-friendly blocks
|
||||
// Bands of B output rows (= input columns) are independent: transpose them in parallel.
|
||||
let band = |(i, chunk): (usize, &mut [f32])| {
|
||||
let bx = i * B;
|
||||
for by in (0..h).step_by(B) {
|
||||
for y in by..(by + B).min(h) {
|
||||
for x in bx..(bx + B).min(w) {
|
||||
let (s, d) = ((y * w + x) * n, ((x - bx) * h + y) * n);
|
||||
chunk[d..d + n].copy_from_slice(&buf[s..s + n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(B * h * n).enumerate().for_each(band);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
out.chunks_mut(B * h * n).enumerate().for_each(band);
|
||||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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);
|
||||
let mut p = vec![0.0f32; ww * wh * n];
|
||||
let fill = |yy: usize, row: &mut [f32]| {
|
||||
let y = win.y0 + yy as i32;
|
||||
for (xx, px) in row.chunks_exact_mut(n).enumerate() {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = src.get(win.x0 + xx as i32, y, c);
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
p.par_chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row));
|
||||
}
|
||||
#[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 {
|
||||
box_rows(&mut p, ww, n, (w - 1) / 2);
|
||||
}
|
||||
let mut t = transpose(&p, ww, wh, n);
|
||||
for w in &boxes {
|
||||
box_rows(&mut t, wh, n, (w - 1) / 2);
|
||||
}
|
||||
let p = transpose(&t, wh, ww, n);
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
for y in 0..oh {
|
||||
let s = ((y + margin as usize) * ww + margin as usize) * n;
|
||||
res[y * ow * n..(y + 1) * ow * n].copy_from_slice(&p[s..s + ow * n]);
|
||||
}
|
||||
unpremultiply(&mut res, n, alpha);
|
||||
res
|
||||
}
|
||||
|
||||
pub(crate) fn boxed(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
match box_widths(&FilterParams::BoxBlur { radius }) {
|
||||
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
None => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f32, f32, &mut Vec<(f32, f32)>)) -> Vec<f32> {
|
||||
@@ -394,102 +305,11 @@ pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: Rad
|
||||
})
|
||||
}
|
||||
|
||||
/// [`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>> {
|
||||
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> 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 {
|
||||
@@ -520,8 +340,6 @@ fn surface_direct(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32) -> Vec<f32>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
#[test]
|
||||
fn box_gaussian_matches_exact_kernel() {
|
||||
@@ -538,168 +356,9 @@ mod tests {
|
||||
let out = Rect::new(0, 0, 120, 8);
|
||||
let k = gaussian_kernel(12.0);
|
||||
let exact = conv_sep(&img, out, &k, &k, true);
|
||||
let fast = box_passes(&img, out, true, &boxes_for_gauss(12.0, 3));
|
||||
let fast = gaussian_boxes(&img, out, true, 12.0);
|
||||
let err = exact.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition the running sums must match: each box applied in turn as a direct
|
||||
/// convolution (cost per pixel proportional to the radius) to `img`, which covers `out` grown
|
||||
/// by every box's reach (its own edge handling included).
|
||||
fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> Vec<f32> {
|
||||
let mut cur = img.clone();
|
||||
let mut left: i32 = boxes.iter().map(|w| (w / 2) as i32).sum();
|
||||
for &w in boxes {
|
||||
left -= (w / 2) as i32;
|
||||
let k = vec![1.0 / w as f32; w];
|
||||
let rect = out.inflate(left);
|
||||
cur = Image { rect, ch: img.ch, data: conv_sep(&cur, rect, &k, &k, alpha) };
|
||||
}
|
||||
cur.data
|
||||
}
|
||||
|
||||
/// Noisy pixels over `r` (alpha varying, some fully transparent).
|
||||
fn noisy(fmt: PixelFormat, r: Rect, seed: u32) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
let mut st = seed;
|
||||
let mut v = Vec::new();
|
||||
for _ in 0..r.width() * r.height() {
|
||||
for c in 0..fmt.channels() {
|
||||
st ^= st << 13;
|
||||
st ^= st >> 17;
|
||||
st ^= st << 5;
|
||||
let x = (st % 256) as f32 / 255.0;
|
||||
v.push(if fmt.alpha && c + 1 == fmt.channels() && st.is_multiple_of(5) { 0.0 } else { x });
|
||||
}
|
||||
}
|
||||
s.write_region(r, &v);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_box_blurs_match_direct_convolution_at_any_radius() {
|
||||
// Gaussian and Box Blur at radii up to several times the layer (37 × 23), filtered as a
|
||||
// document layer (canvas edge repeated, as Photoshop does) and on their own (transparent
|
||||
// beyond), at every depth, with and without alpha, 1 to 5 channels.
|
||||
let canvas = Rect::new(0, 0, 37, 23);
|
||||
let formats = [
|
||||
PixelFormat::RGBA8,
|
||||
PixelFormat::RGBA16,
|
||||
PixelFormat::RGBA32F,
|
||||
PixelFormat::GRAY8,
|
||||
PixelFormat::CMYKA8,
|
||||
PixelFormat::new(ColorMode::Lab, SampleType::F32, false),
|
||||
];
|
||||
let params = [
|
||||
FilterParams::GaussianBlur { radius: 4.5 },
|
||||
FilterParams::GaussianBlur { radius: 9.3 },
|
||||
FilterParams::GaussianBlur { radius: 40.0 },
|
||||
FilterParams::BoxBlur { radius: 5.0 },
|
||||
FilterParams::BoxBlur { radius: 70.0 },
|
||||
];
|
||||
for (i, fmt) in formats.into_iter().enumerate() {
|
||||
let s = noisy(fmt, canvas, 0x9e37_79b9 + i as u32);
|
||||
// A level of the depth (colour and alpha are each rounded on write) plus float noise.
|
||||
let tol = match fmt.sample {
|
||||
SampleType::U8 => 1.0 / 255.0,
|
||||
SampleType::U16 => 1.0 / 65535.0,
|
||||
_ => 0.0,
|
||||
} + 2e-5;
|
||||
for p in ¶ms {
|
||||
let boxes = box_widths(p).expect("a box-pass blur");
|
||||
let reach: i32 = boxes.iter().map(|w| (w / 2) as i32).sum();
|
||||
let area = crate::output_area(p, s.content_bounds(), canvas, None);
|
||||
for (got, img, out) in [
|
||||
(crate::apply_in(&s, p, area, canvas, None, canvas), Image::read_clamped(&s, canvas.inflate(reach), canvas), canvas),
|
||||
(crate::apply(&s, p, area, canvas, None), Image::read(&s, area.inflate(reach)), area),
|
||||
] {
|
||||
// Compared alpha-weighted: colour is meaningless where (nearly) transparent.
|
||||
let (mut got, mut want) = (got.read_region(out), direct_boxes(&img, out, &boxes, fmt.alpha));
|
||||
premultiply(&mut got, img.ch, fmt.alpha);
|
||||
premultiply(&mut want, img.ch, fmt.alpha);
|
||||
let err = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err <= tol, "{} on {fmt:?} over {out:?}: max error {err}", p.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_box_blurs_mix_by_the_selection_and_stop_when_cancelled() {
|
||||
let canvas = Rect::new(0, 0, 37, 23);
|
||||
let s = noisy(PixelFormat::RGBA32F, canvas, 7);
|
||||
let p = FilterParams::GaussianBlur { radius: 12.0 };
|
||||
let full = crate::apply_in(&s, &p, canvas, canvas, None, canvas);
|
||||
let half = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.5]);
|
||||
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
|
||||
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
|
||||
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
|
||||
for c in 0..4 {
|
||||
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
|
||||
}
|
||||
}
|
||||
let cancel = || true;
|
||||
let ctl = Interrupt::cancel_only(&cancel);
|
||||
assert!(crate::apply_in_with(&s, &p, canvas, canvas, None, canvas, &ctl).is_none());
|
||||
}
|
||||
|
||||
#[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,29 +29,6 @@
|
||||
|
||||
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.
|
||||
@@ -460,33 +437,6 @@ 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
|
||||
}
|
||||
@@ -919,44 +869,6 @@ 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_with};
|
||||
use crate::poisson::membrane_fill_with;
|
||||
use crate::inpaint::{CompleteParams, complete};
|
||||
use crate::poisson::membrane_fill;
|
||||
|
||||
/// Options for [`fill`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -161,27 +161,11 @@ 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 Ok(img.to_vec());
|
||||
return img.to_vec();
|
||||
}
|
||||
let params = CompleteParams { seed: opts.seed, ..Default::default() };
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
@@ -224,11 +208,7 @@ pub fn fill_with(
|
||||
}
|
||||
oy += v.h + gap;
|
||||
}
|
||||
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 filled = complete(mw, mh, ch, &mimg, &unknown, ¶ms);
|
||||
let mut out = img.to_vec();
|
||||
match filled {
|
||||
Some(f) => {
|
||||
@@ -241,28 +221,16 @@ pub fn fill_with(
|
||||
}
|
||||
}
|
||||
}
|
||||
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
|
||||
None => out = membrane_fill(w, h, ch, img, hole),
|
||||
}
|
||||
ctl.check()?;
|
||||
if opts.color_adaptation > 0.0 {
|
||||
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation, ctl)?;
|
||||
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation);
|
||||
}
|
||||
ctl.progress(1.0);
|
||||
Ok(out)
|
||||
out
|
||||
}
|
||||
|
||||
/// Pull the fill's low frequencies towards the membrane interpolation of the hole boundary.
|
||||
#[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> {
|
||||
fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out: &mut [f32], k: f32) {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
@@ -272,9 +240,7 @@ fn adapt_colors(
|
||||
}
|
||||
}
|
||||
let r = ((x1 - x0).max(y1 - y0) / 6).clamp(1, 32);
|
||||
let membrane = membrane_fill_with(w, h, ch, img, hole, ctl)?;
|
||||
ctl.progress(0.97);
|
||||
ctl.check()?;
|
||||
let membrane = membrane_fill(w, h, ch, img, hole);
|
||||
let low = box_blur(w, h, ch, out, r);
|
||||
for (i, &hl) in hole.iter().enumerate() {
|
||||
if hl {
|
||||
@@ -284,7 +250,6 @@ fn adapt_colors(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -298,26 +263,6 @@ 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,17 +172,7 @@ 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;
|
||||
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
|
||||
std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 })
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur (three box passes) of an interleaved buffer.
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,6 @@ impl Image {
|
||||
if self.rect.contains(x, y) { self.data[self.index(x, y) + c] } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Samples of the pixels of a one-pixel-high rectangle (must be inside).
|
||||
#[inline]
|
||||
pub fn row(&self, r: Rect) -> &[f32] {
|
||||
let i = self.index(r.x0, r.y0);
|
||||
&self.data[i..i + r.width() as usize * self.ch]
|
||||
}
|
||||
|
||||
/// Pixel slice at `(x, y)` (must be inside).
|
||||
#[inline]
|
||||
pub fn px(&self, x: i32, y: i32) -> &[f32] {
|
||||
|
||||
@@ -164,8 +164,6 @@ 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<'_> {
|
||||
@@ -213,9 +211,6 @@ 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;
|
||||
@@ -297,9 +292,6 @@ 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;
|
||||
@@ -357,24 +349,9 @@ 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 Ok(Some(img.to_vec())) };
|
||||
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return 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.
|
||||
@@ -382,7 +359,6 @@ pub fn complete_with(
|
||||
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;
|
||||
}
|
||||
@@ -394,10 +370,7 @@ pub fn complete_with(
|
||||
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 {
|
||||
@@ -420,9 +393,6 @@ pub fn complete_with(
|
||||
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;
|
||||
@@ -436,22 +406,19 @@ pub fn complete_with(
|
||||
}
|
||||
if valid_list.is_empty() {
|
||||
if li == 0 {
|
||||
return Ok(None);
|
||||
return 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, ctl: *ctl };
|
||||
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets };
|
||||
// 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;
|
||||
@@ -484,9 +451,6 @@ pub fn complete_with(
|
||||
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);
|
||||
}
|
||||
@@ -500,19 +464,13 @@ pub fn complete_with(
|
||||
(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));
|
||||
}
|
||||
Ok(prev.map(|(_, _, img)| img))
|
||||
prev.map(|(_, _, img)| img)
|
||||
}
|
||||
|
||||
/// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the
|
||||
|
||||
+15
-161
@@ -33,12 +33,10 @@ pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
pub mod hdr;
|
||||
pub mod histogram;
|
||||
mod image;
|
||||
pub mod inpaint;
|
||||
pub mod lens;
|
||||
pub mod liquify;
|
||||
pub mod magnetic;
|
||||
pub mod matting;
|
||||
mod noise;
|
||||
mod oil;
|
||||
@@ -53,7 +51,6 @@ pub mod poisson;
|
||||
pub mod puppet;
|
||||
pub mod pyramid;
|
||||
pub mod quantize;
|
||||
mod relight;
|
||||
mod render;
|
||||
pub mod render2;
|
||||
pub mod resample;
|
||||
@@ -71,7 +68,6 @@ pub mod tone;
|
||||
pub mod transform;
|
||||
pub mod trap;
|
||||
pub mod vanishing;
|
||||
pub mod vectorscope;
|
||||
mod video;
|
||||
pub mod warp;
|
||||
pub mod wideangle;
|
||||
@@ -413,16 +409,6 @@ 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 %.
|
||||
@@ -599,7 +585,6 @@ 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",
|
||||
@@ -641,8 +626,6 @@ 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),
|
||||
@@ -776,12 +759,6 @@ 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,
|
||||
@@ -884,20 +861,6 @@ 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 {
|
||||
@@ -917,29 +880,9 @@ 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 Some(out);
|
||||
}
|
||||
if let Some(boxes) = blur::box_widths(params) {
|
||||
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
|
||||
return out;
|
||||
}
|
||||
let fmt = surface.format();
|
||||
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
|
||||
@@ -955,19 +898,7 @@ pub fn apply_tiled_with(
|
||||
}
|
||||
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,
|
||||
@@ -986,9 +917,20 @@ pub fn apply_tiled_with(
|
||||
};
|
||||
let mut data = kernel(params, src, *t, &ctx);
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(&mut data, *t, sel, src);
|
||||
let n = src.ch;
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(n).enumerate() {
|
||||
let (xx, yy) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = sel.pixel(xx, yy)[0].clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = src.get(xx, yy, c);
|
||||
*v = o + (*v - o) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
};
|
||||
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
|
||||
@@ -1001,9 +943,6 @@ pub fn apply_tiled_with(
|
||||
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::*;
|
||||
@@ -1011,97 +950,12 @@ pub fn apply_tiled_with(
|
||||
};
|
||||
#[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();
|
||||
ctl.progress(1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`apply_tiled_with`] for the blurs run as box passes ([`blur::box_widths`]): the whole area at
|
||||
/// once (in column bands of about [`RESULT_BUDGET`] bytes of floats), so the wide halo of a large
|
||||
/// radius is read once rather than around every tile. Pixels beyond `extent` repeat its edge, as
|
||||
/// [`Image::read_clamped`] does for tiles; without one the surface is read (transparent where empty).
|
||||
fn apply_box_blur(
|
||||
mut out: Surface,
|
||||
surface: &Surface,
|
||||
area: Rect,
|
||||
extent: Option<Rect>,
|
||||
selection: Option<&Surface>,
|
||||
boxes: &[usize],
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Option<Surface> {
|
||||
let fmt = surface.format();
|
||||
let n = fmt.channels();
|
||||
let reach = i32::try_from(boxes.iter().map(|w| w / 2).sum::<usize>()).unwrap_or(i32::MAX);
|
||||
let grown = area.inflate(reach);
|
||||
let src = extent.map(|e| grown.intersect(&e)).filter(|r| !r.is_empty()).unwrap_or(grown);
|
||||
let read = |r: Rect, buf: &mut Vec<f32>| surface.read_region_into(r, buf);
|
||||
// Per output column: the horizontal results for every source row, plus the final samples.
|
||||
let column_bytes = (src.height() as usize + area.height() as usize) * n * std::mem::size_of::<f32>();
|
||||
let band = i32::try_from(RESULT_BUDGET / column_bytes.max(1)).unwrap_or(i32::MAX).max(blur::STRIP) / blur::STRIP * blur::STRIP;
|
||||
let bands: Vec<Rect> = (area.x0..area.x1).step_by(band as usize).map(|x| Rect::new(x, area.y0, x.saturating_add(band).min(area.x1), area.y1)).collect();
|
||||
for (i, b) in bands.iter().enumerate() {
|
||||
let strips = blur::box_blur(src, &read, *b, n, fmt.alpha, boxes, ctl)?;
|
||||
// Each strip lies in one tile column: the columns' tiles are written in parallel.
|
||||
let mut columns: Vec<(Surface, Vec<blur::Strip>)> = Vec::new();
|
||||
for (t, data) in strips {
|
||||
match columns.last_mut() {
|
||||
Some((_, v)) if v.first().is_some_and(|(r, _)| r.x0.div_euclid(photocraft_geom::TILE_SIZE) == t.x0.div_euclid(photocraft_geom::TILE_SIZE)) => {
|
||||
v.push((t, data))
|
||||
}
|
||||
_ => columns.push((Surface::with_default(fmt, &surface.default_pixel()), vec![(t, data)])),
|
||||
}
|
||||
}
|
||||
for (s, v) in &mut columns {
|
||||
let r = v.iter().fold(Rect::EMPTY, |acc, (t, _)| if acc.is_empty() { *t } else { acc.union(t) });
|
||||
s.put_tiles(out.take_tiles(r));
|
||||
}
|
||||
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
|
||||
for (t, data) in v.iter_mut() {
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t));
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
columns.par_iter_mut().for_each(write);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
columns.iter_mut().for_each(write);
|
||||
for (s, _) in columns {
|
||||
out.put_tiles(s.tiles().map(|(c, t)| (*c, t.clone())));
|
||||
}
|
||||
ctl.progress(0.98 * (i + 1) as f32 / bands.len() as f32);
|
||||
}
|
||||
out.prune();
|
||||
ctl.progress(1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
|
||||
/// coverage (channel 0 of `sel`).
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = orig.get(x, y, c);
|
||||
*v = o + (*v - o) * k;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bytes of float filter results held at once by [`apply_tiled`].
|
||||
|
||||
+3
-144
@@ -38,7 +38,6 @@ 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).
|
||||
@@ -48,7 +47,7 @@ pub enum LiquifyTool {
|
||||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub const ALL: [LiquifyTool; 11] = [
|
||||
pub const ALL: [LiquifyTool; 10] = [
|
||||
LiquifyTool::ForwardWarp,
|
||||
LiquifyTool::Reconstruct,
|
||||
LiquifyTool::Smooth,
|
||||
@@ -59,7 +58,6 @@ impl LiquifyTool {
|
||||
LiquifyTool::PushLeft,
|
||||
LiquifyTool::Freeze,
|
||||
LiquifyTool::Thaw,
|
||||
LiquifyTool::LassoMask,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -74,7 +72,6 @@ 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",
|
||||
@@ -89,7 +86,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 | LiquifyTool::LassoMask)
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +124,7 @@ pub struct LiquifyStroke {
|
||||
pub rate: f64,
|
||||
#[serde(default)]
|
||||
pub points: Vec<Vec<f64>>,
|
||||
/// `reconstructAll` only: how much to restore, 0..100. `lassoMask`: 1.0/none freezes the
|
||||
/// polygon, 0.0 thaws it.
|
||||
/// `reconstructAll` only: how much to restore, 0..100.
|
||||
#[serde(default)]
|
||||
pub amount: Option<f64>,
|
||||
}
|
||||
@@ -220,7 +216,6 @@ 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) {
|
||||
@@ -247,60 +242,6 @@ 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.
|
||||
@@ -335,9 +276,6 @@ 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);
|
||||
@@ -761,85 +699,6 @@ 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] {
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
//! Magnetic Lasso edge tracing: a selection border that snaps to the strongest edges near the
|
||||
//! pointer.
|
||||
//!
|
||||
//! Between two fastening points the border is the cheapest 8-connected pixel path, where pixels on
|
||||
//! a strong, well-localised edge are cheap and the rest are expensive: the "live wire" of
|
||||
//! Mortensen & Barrett, *Intelligent Scissors for Image Composition* (SIGGRAPH 1995). A link costs
|
||||
//! a weighted sum of an edge-strength term, an edge-centre term (non-maximum suppression across the
|
||||
//! edge, in place of the paper's Laplacian zero crossings) and a gradient-direction term that keeps
|
||||
//! the border running along the edge. The search stays in a corridor, the pixels within the
|
||||
//! detection width of the pointer's path, and a small pull towards that path keeps the border on
|
||||
//! the pointer where there is no edge, so it never jumps to an edge the pointer did not go near.
|
||||
//! Points on an edge are placed at its sub-pixel centre, so the border lands on the edge rather than
|
||||
//! half a pixel beside it.
|
||||
//!
|
||||
//! Pixels are read lazily in blocks through a caller-supplied fetch (a layer's pixels or the
|
||||
//! composite), so tracing on a 36 MP image reads only the area around the border.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
use std::f32::consts::{PI, SQRT_2};
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// Side of the cached pixel blocks (px).
|
||||
const BLOCK: i32 = 128;
|
||||
/// Blocks kept before the cache starts over (128² RGBA8 is 64 KiB, so at most 64 MiB).
|
||||
const MAX_BLOCKS: usize = 1024;
|
||||
/// Longest stretch of the pointer's path searched at once (px). Longer paths are traced in pieces
|
||||
/// that meet on an edge, which bounds every search to a small area.
|
||||
const PIECE: f64 = 256.0;
|
||||
/// Longest pointer path [`Tracer::trace`] follows (px); a longer one is returned as is.
|
||||
pub const MAX_GUIDE_LENGTH: f64 = 262_144.0;
|
||||
/// Detection width limits (px), as in Photoshop's options bar.
|
||||
pub const WIDTH_RANGE: (f64, f64) = (1.0, 256.0);
|
||||
|
||||
// Link cost weights: Mortensen & Barrett's 0.43 (edge centre) / 0.43 (strength) / 0.14
|
||||
// (direction), a cost per pixel of length so the border never wanders along an edge for free, and
|
||||
// the pull towards the pointer's path.
|
||||
const W_CENTRE: f32 = 0.43;
|
||||
const W_STRENGTH: f32 = 0.43;
|
||||
const W_DIRECTION: f32 = 0.14;
|
||||
const W_LENGTH: f32 = 0.02;
|
||||
const W_GUIDE: f32 = 0.4;
|
||||
/// The direction term where either pixel has no edge direction (the mean of the term).
|
||||
const NEUTRAL_DIRECTION: f32 = 2.0 / 3.0;
|
||||
|
||||
/// The 8 neighbours, in the order `Search::from` records them.
|
||||
const DIRS: [(i32, i32); 8] = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)];
|
||||
|
||||
/// Detection settings: the Magnetic Lasso options bar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Detection width (px): the border follows only edges this close to the pointer's path.
|
||||
pub width: f64,
|
||||
/// Contrast (0..1): steps weaker than this, in every channel, are not edges.
|
||||
pub contrast: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings { width: 10.0, contrast: 0.1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Settings clamped to their ranges (width 1..256 px, contrast 1..100 %); NaN takes the default.
|
||||
pub fn new(width: f64, contrast: f32) -> Self {
|
||||
let d = Settings::default();
|
||||
let width = if width.is_finite() { width.clamp(WIDTH_RANGE.0, WIDTH_RANGE.1) } else { d.width };
|
||||
let contrast = if contrast.is_finite() { contrast.clamp(0.01, 1.0) } else { d.contrast };
|
||||
Settings { width, contrast }
|
||||
}
|
||||
|
||||
/// The corridor's radius: the detection width plus room for an 8-connected path.
|
||||
fn radius(&self) -> f64 {
|
||||
self.width + 1.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads straight-alpha RGBA8 pixels of a rectangle inside the image, row-major. A result of the
|
||||
/// wrong length reads as transparent.
|
||||
pub type Fetch<'a> = dyn FnMut(Rect) -> Vec<[u8; 4]> + 'a;
|
||||
|
||||
/// Edge tracing over one image, caching the pixels it has read.
|
||||
#[derive(Debug)]
|
||||
pub struct Tracer {
|
||||
bounds: Rect,
|
||||
blocks: HashMap<(i32, i32), Vec<[u8; 4]>>,
|
||||
}
|
||||
|
||||
impl Tracer {
|
||||
/// A tracer for an image covering `bounds` (document pixels).
|
||||
pub fn new(bounds: Rect) -> Self {
|
||||
Tracer { bounds, blocks: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.bounds
|
||||
}
|
||||
|
||||
/// Pixels read so far (for tests and diagnostics).
|
||||
pub fn cached_pixels(&self) -> usize {
|
||||
self.blocks.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
/// Where a fastening point near `p` goes: the centre of the most prominent edge within the
|
||||
/// detection width, or `p` itself (kept inside the image) when no edge there reaches the
|
||||
/// contrast. `None` when `p` is not a finite point or the image is empty.
|
||||
pub fn snap(&mut self, fetch: &mut Fetch, p: [f64; 2], s: Settings) -> Option<[f64; 2]> {
|
||||
let s = Settings::new(s.width, s.contrast);
|
||||
let p = self.clamp_point(p)?;
|
||||
let r = s.width;
|
||||
let (px, py) = self.pixel(p);
|
||||
let ri = r.ceil() as i32 + 1;
|
||||
let area = Rect::new(px.saturating_sub(ri), py.saturating_sub(ri), px.saturating_add(ri + 1), py.saturating_add(ri + 1)).intersect(&self.bounds);
|
||||
let f = self.field(fetch, area);
|
||||
let mut best: Option<(f32, [f64; 2])> = None;
|
||||
for y in area.y0..area.y1 {
|
||||
for x in area.x0..area.x1 {
|
||||
let Some(i) = f.index(x, y) else { continue };
|
||||
let strength = f.strength.get(i).copied().unwrap_or(0.0);
|
||||
if strength < s.contrast || !f.ridge.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let d = dist([x as f64 + 0.5, y as f64 + 0.5], p);
|
||||
if d > r {
|
||||
continue;
|
||||
}
|
||||
// The strongest edge, a little in favour of the nearer one.
|
||||
let score = strength * (1.0 - 0.25 * (d / r) as f32);
|
||||
if best.is_none_or(|(b, _)| score > b) {
|
||||
best = Some((score, f.point(i, x, y, s.contrast)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(best.map_or(p, |(_, q)| q))
|
||||
}
|
||||
|
||||
/// The border from `from` to `to` along the edges near `guide`, the pointer's path between
|
||||
/// them (`from` and `to` are its ends whatever it holds). It starts at `from` and ends at `to`
|
||||
/// exactly (both kept inside the image). Empty when either is not a finite point or the image
|
||||
/// is empty.
|
||||
pub fn trace(&mut self, fetch: &mut Fetch, from: [f64; 2], to: [f64; 2], guide: &[[f64; 2]], s: Settings) -> Vec<[f64; 2]> {
|
||||
let s = Settings::new(s.width, s.contrast);
|
||||
let (Some(from), Some(to)) = (self.clamp_point(from), self.clamp_point(to)) else { return Vec::new() };
|
||||
let mut g = Vec::with_capacity(guide.len().saturating_add(2));
|
||||
g.push(from);
|
||||
g.extend(guide.iter().filter_map(|q| self.clamp_point(*q)));
|
||||
g.push(to);
|
||||
// A coarse guide is enough for the corridor, and much cheaper to draw.
|
||||
let g = simplify(&g, 0.5);
|
||||
if length(&g) > MAX_GUIDE_LENGTH {
|
||||
return g;
|
||||
}
|
||||
let pieces = split(&g, PIECE);
|
||||
let mut out = vec![from];
|
||||
let mut cur = from;
|
||||
let last = pieces.len().saturating_sub(1);
|
||||
for (k, piece) in pieces.iter().enumerate() {
|
||||
let target = if k == last { to } else { piece.last().and_then(|q| self.snap(fetch, *q, s)).unwrap_or(to) };
|
||||
out.extend(self.trace_piece(fetch, cur, target, piece, s).into_iter().skip(1));
|
||||
cur = target;
|
||||
}
|
||||
// Within half a pixel: drops the staircase of pixel steps, keeps the curve of an edge.
|
||||
simplify(&out, 0.5)
|
||||
}
|
||||
|
||||
/// One piece of [`Tracer::trace`]: the cheapest path from `a` to `b` in the corridor around
|
||||
/// `guide`, from `a` to `b` inclusive.
|
||||
fn trace_piece(&mut self, fetch: &mut Fetch, a: [f64; 2], b: [f64; 2], guide: &[[f64; 2]], s: Settings) -> Vec<[f64; 2]> {
|
||||
let rad = s.radius();
|
||||
let (ax, ay) = self.pixel(a);
|
||||
let (bx, by) = self.pixel(b);
|
||||
let mut bbox = Rect::new(ax.min(bx), ay.min(by), ax.max(bx) + 1, ay.max(by) + 1);
|
||||
for q in guide {
|
||||
let (x, y) = self.pixel(*q);
|
||||
bbox = bbox.union(&Rect::new(x, y, x + 1, y + 1));
|
||||
}
|
||||
let area = bbox.inflate(rad.ceil() as i32 + 1).intersect(&self.bounds);
|
||||
let f = self.field(fetch, area);
|
||||
let mut near = corridor(area, guide, rad);
|
||||
let (Some(start), Some(goal)) = (f.index(ax, ay), f.index(bx, by)) else { return vec![a, b] };
|
||||
for i in [start, goal] {
|
||||
if let Some(v) = near.get_mut(i) {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
let Some(cells) = search(&f, &near, (rad * rad) as f32, start, goal, s.contrast) else { return vec![a, b] };
|
||||
let mut pts: Vec<[f64; 2]> = cells.iter().map(|&i| f.point_at(i, s.contrast)).collect();
|
||||
match pts.len() {
|
||||
0 | 1 => vec![a, b],
|
||||
n => {
|
||||
pts[0] = a;
|
||||
pts[n - 1] = b;
|
||||
pts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` kept inside the image, or `None` if it is not a finite point or the image is empty.
|
||||
fn clamp_point(&self, p: [f64; 2]) -> Option<[f64; 2]> {
|
||||
let b = self.bounds;
|
||||
if b.is_empty() || !p[0].is_finite() || !p[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some([p[0].clamp(b.x0 as f64, b.x1 as f64), p[1].clamp(b.y0 as f64, b.y1 as f64)])
|
||||
}
|
||||
|
||||
/// The pixel under a point (clamped to the image).
|
||||
fn pixel(&self, p: [f64; 2]) -> (i32, i32) {
|
||||
let b = self.bounds;
|
||||
let c = |v: f64, lo: i32, hi: i32| if v.is_finite() { (v.floor() as i32).clamp(lo, (hi - 1).max(lo)) } else { lo };
|
||||
(c(p[0], b.x0, b.x1), c(p[1], b.y0, b.y1))
|
||||
}
|
||||
|
||||
/// Edge features over `area` (inside the image).
|
||||
fn field(&mut self, fetch: &mut Fetch, area: Rect) -> Field {
|
||||
// Sobel reads one ring beyond the gradient, and non-maximum suppression one beyond the area.
|
||||
let ext = area.inflate(2).intersect(&self.bounds);
|
||||
let px = self.pixels(fetch, ext);
|
||||
Field::new(area, ext, &px)
|
||||
}
|
||||
|
||||
/// Straight RGBA8 pixels of `r` (inside the image), through the block cache.
|
||||
fn pixels(&mut self, fetch: &mut Fetch, r: Rect) -> Vec<[u8; 4]> {
|
||||
let w = r.width() as usize;
|
||||
let mut out = vec![[0u8; 4]; w * r.height() as usize];
|
||||
if r.is_empty() {
|
||||
return out;
|
||||
}
|
||||
if self.blocks.len() > MAX_BLOCKS {
|
||||
self.blocks.clear();
|
||||
}
|
||||
let (bx0, by0) = (r.x0.div_euclid(BLOCK), r.y0.div_euclid(BLOCK));
|
||||
let (bx1, by1) = ((r.x1 - 1).div_euclid(BLOCK), (r.y1 - 1).div_euclid(BLOCK));
|
||||
for by in by0..=by1 {
|
||||
for bx in bx0..=bx1 {
|
||||
let br = Rect::new(
|
||||
bx.saturating_mul(BLOCK),
|
||||
by.saturating_mul(BLOCK),
|
||||
bx.saturating_add(1).saturating_mul(BLOCK),
|
||||
by.saturating_add(1).saturating_mul(BLOCK),
|
||||
)
|
||||
.intersect(&self.bounds);
|
||||
if br.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let n = br.width() as usize * br.height() as usize;
|
||||
let block = self.blocks.entry((bx, by)).or_insert_with(|| {
|
||||
let v = fetch(br);
|
||||
if v.len() == n { v } else { vec![[0; 4]; n] }
|
||||
});
|
||||
let ov = br.intersect(&r);
|
||||
let len = ov.width() as usize;
|
||||
for y in ov.y0..ov.y1 {
|
||||
let src = (y - br.y0) as usize * br.width() as usize + (ov.x0 - br.x0) as usize;
|
||||
let dst = (y - r.y0) as usize * w + (ov.x0 - r.x0) as usize;
|
||||
if let (Some(s), Some(d)) = (block.get(src..src + len), out.get_mut(dst..dst + len)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge features of the pixels in an area.
|
||||
struct Field {
|
||||
area: Rect,
|
||||
w: usize,
|
||||
/// Edge strength: the steepest channel's Sobel gradient, scaled so a sharp step of height `h`
|
||||
/// reads `h` (capped at 1).
|
||||
strength: Vec<f32>,
|
||||
/// Unit gradient of that channel (zero on flat pixels).
|
||||
grad: Vec<[f32; 2]>,
|
||||
/// The pixel is the strongest across its edge (non-maximum suppression).
|
||||
ridge: Vec<bool>,
|
||||
/// From the pixel centre to the edge's sub-pixel centre, along the gradient.
|
||||
offset: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// Features over `area` from the straight RGBA8 pixels `px` of `ext` (the area plus up to two
|
||||
/// pixels around it, inside the image; beyond it the edge pixels repeat).
|
||||
fn new(area: Rect, ext: Rect, px: &[[u8; 4]]) -> Field {
|
||||
let (ew, eh) = (ext.width() as i32, ext.height() as i32);
|
||||
// Premultiplied channels: an opaque shape on transparency has an edge, transparent
|
||||
// pixels of different colours do not.
|
||||
let ch: Vec<[f32; 4]> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
[f32::from(p[0]) / 255.0 * a, f32::from(p[1]) / 255.0 * a, f32::from(p[2]) / 255.0 * a, a]
|
||||
})
|
||||
.collect();
|
||||
let at = |x: i32, y: i32| -> [f32; 4] {
|
||||
let (x, y) = ((x - ext.x0).clamp(0, (ew - 1).max(0)), (y - ext.y0).clamp(0, (eh - 1).max(0)));
|
||||
ch.get(y as usize * ew.max(0) as usize + x as usize).copied().unwrap_or_default()
|
||||
};
|
||||
// Gradients over the area plus one ring (non-maximum suppression looks across it).
|
||||
let g1 = area.inflate(1).intersect(&ext);
|
||||
let gw = g1.width() as usize;
|
||||
let mut st = vec![0.0f32; gw * g1.height() as usize];
|
||||
let mut gd = vec![[0.0f32; 2]; st.len()];
|
||||
for y in g1.y0..g1.y1 {
|
||||
for x in g1.x0..g1.x1 {
|
||||
let (nw, n, ne) = (at(x - 1, y - 1), at(x, y - 1), at(x + 1, y - 1));
|
||||
let (w, e) = (at(x - 1, y), at(x + 1, y));
|
||||
let (sw, s, se) = (at(x - 1, y + 1), at(x, y + 1), at(x + 1, y + 1));
|
||||
let mut best = (0.0f32, [0.0f32; 2]);
|
||||
for c in 0..4 {
|
||||
let gx = (ne[c] + 2.0 * e[c] + se[c]) - (nw[c] + 2.0 * w[c] + sw[c]);
|
||||
let gy = (sw[c] + 2.0 * s[c] + se[c]) - (nw[c] + 2.0 * n[c] + ne[c]);
|
||||
let m2 = gx * gx + gy * gy;
|
||||
if m2 > best.0 {
|
||||
best = (m2, [gx, gy]);
|
||||
}
|
||||
}
|
||||
let mag = best.0.sqrt();
|
||||
let i = (y - g1.y0) as usize * gw + (x - g1.x0) as usize;
|
||||
if let (Some(sv), Some(gv)) = (st.get_mut(i), gd.get_mut(i)) {
|
||||
*sv = (mag / 4.0).min(1.0);
|
||||
*gv = if mag > 1e-6 { [best.1[0] / mag, best.1[1] / mag] } else { [0.0; 2] };
|
||||
}
|
||||
}
|
||||
}
|
||||
let s_at =
|
||||
|x: i32, y: i32| -> f32 { if g1.contains(x, y) { st.get((y - g1.y0) as usize * gw + (x - g1.x0) as usize).copied().unwrap_or(0.0) } else { 0.0 } };
|
||||
let w = area.width() as usize;
|
||||
let n = w * area.height() as usize;
|
||||
let mut f = Field { area, w, strength: vec![0.0; n], grad: vec![[0.0; 2]; n], ridge: vec![false; n], offset: vec![[0.0; 2]; n] };
|
||||
for y in area.y0..area.y1 {
|
||||
for x in area.x0..area.x1 {
|
||||
let s0 = s_at(x, y);
|
||||
let g = if g1.contains(x, y) { gd.get((y - g1.y0) as usize * gw + (x - g1.x0) as usize).copied().unwrap_or_default() } else { [0.0; 2] };
|
||||
let i = (y - area.y0) as usize * w + (x - area.x0) as usize;
|
||||
if let Some(v) = f.strength.get_mut(i) {
|
||||
*v = s0;
|
||||
}
|
||||
if let Some(v) = f.grad.get_mut(i) {
|
||||
*v = g;
|
||||
}
|
||||
if g == [0.0; 2] {
|
||||
continue;
|
||||
}
|
||||
// The neighbours across the edge: the gradient rounded to one of 8 directions.
|
||||
let q = |v: f32, o: f32| if v.abs() >= 0.4142 * o.abs() { v.signum() as i32 } else { 0 };
|
||||
let (dx, dy) = (q(g[0], g[1]), q(g[1], g[0]));
|
||||
let (sm, sp) = (s_at(x - dx, y - dy), s_at(x + dx, y + dy));
|
||||
if s0 > 0.0 && s0 >= sm && s0 >= sp {
|
||||
if let Some(v) = f.ridge.get_mut(i) {
|
||||
*v = true;
|
||||
}
|
||||
// The peak of the parabola through the three strengths.
|
||||
let denom = sm - 2.0 * s0 + sp;
|
||||
let t = if denom < -1e-6 { (0.5 * (sm - sp) / denom).clamp(-0.5, 0.5) } else { 0.0 };
|
||||
if let Some(v) = f.offset.get_mut(i) {
|
||||
*v = [t * dx as f32, t * dy as f32];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn h(&self) -> usize {
|
||||
self.strength.len() / self.w.max(1)
|
||||
}
|
||||
|
||||
fn index(&self, x: i32, y: i32) -> Option<usize> {
|
||||
self.area.contains(x, y).then(|| (y - self.area.y0) as usize * self.w + (x - self.area.x0) as usize)
|
||||
}
|
||||
|
||||
/// Where the border runs through pixel `i` at (`x`, `y`): the edge's sub-pixel centre on an edge
|
||||
/// at least `contrast` strong, else the pixel centre.
|
||||
fn point(&self, i: usize, x: i32, y: i32, contrast: f32) -> [f64; 2] {
|
||||
let on_edge = self.ridge.get(i).copied().unwrap_or(false) && self.strength.get(i).copied().unwrap_or(0.0) >= contrast;
|
||||
let o = if on_edge { self.offset.get(i).copied().unwrap_or_default() } else { [0.0; 2] };
|
||||
[x as f64 + 0.5 + f64::from(o[0]), y as f64 + 0.5 + f64::from(o[1])]
|
||||
}
|
||||
|
||||
fn point_at(&self, i: usize, contrast: f32) -> [f64; 2] {
|
||||
let (x, y) = ((i % self.w.max(1)) as i32 + self.area.x0, (i / self.w.max(1)) as i32 + self.area.y0);
|
||||
self.point(i, x, y, contrast)
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared distance from each pixel centre of `area` to the polyline `guide`, infinite beyond
|
||||
/// `rad`: the corridor the border may use.
|
||||
fn corridor(area: Rect, guide: &[[f64; 2]], rad: f64) -> Vec<f32> {
|
||||
let w = area.width() as usize;
|
||||
let mut near = vec![f32::INFINITY; w * area.height() as usize];
|
||||
let r2 = rad * rad;
|
||||
let single;
|
||||
let segs: &[[f64; 2]] = match guide {
|
||||
[p] => {
|
||||
single = [*p, *p];
|
||||
&single
|
||||
}
|
||||
_ => guide,
|
||||
};
|
||||
for s in segs.windows(2) {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
let lo = |u: f64, v: f64| (u.min(v) - rad).floor() as i32;
|
||||
let hi = |u: f64, v: f64| (u.max(v) + rad).ceil() as i32 + 1;
|
||||
let r = Rect::new(lo(a[0], b[0]), lo(a[1], b[1]), hi(a[0], b[0]), hi(a[1], b[1])).intersect(&area);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let d2 = dist2_segment([x as f64 + 0.5, y as f64 + 0.5], a, b);
|
||||
if d2 <= r2
|
||||
&& let Some(v) = near.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
|
||||
{
|
||||
*v = v.min(d2 as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
near
|
||||
}
|
||||
|
||||
/// The cheapest 8-connected path from `start` to `goal` through the corridor `near` (squared
|
||||
/// distances to the guide, `rad2` at its rim), as field indices from `start` to `goal`.
|
||||
fn search(f: &Field, near: &[f32], rad2: f32, start: usize, goal: usize, contrast: f32) -> Option<Vec<usize>> {
|
||||
let (w, h) = (f.w, f.h());
|
||||
let n = w * h;
|
||||
// Cost of entering each pixel, per pixel of length, and its edge tangent.
|
||||
let mut node = vec![f32::INFINITY; n];
|
||||
let mut tangent = vec![[0.0f32; 2]; n];
|
||||
for i in 0..n {
|
||||
let d2 = near.get(i).copied().unwrap_or(f32::INFINITY);
|
||||
if !d2.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let s = f.strength.get(i).copied().unwrap_or(0.0);
|
||||
let on_edge = s >= contrast && f.ridge.get(i).copied().unwrap_or(false);
|
||||
// No pull below the contrast, full pull from twice the contrast, a little more for
|
||||
// stronger edges still.
|
||||
let good = ((s - contrast) / contrast.max(0.02)).clamp(0.0, 1.0) * (0.6 + 0.4 * s.min(1.0));
|
||||
// Grows linearly away from the pointer's path, so the border hugs it where there is no edge.
|
||||
let pull = (d2 / rad2.max(1.0)).sqrt().min(1.0);
|
||||
let centre = if on_edge { 0.0 } else { 1.0 };
|
||||
if let Some(v) = node.get_mut(i) {
|
||||
*v = W_CENTRE * centre + W_STRENGTH * (1.0 - good) + W_LENGTH + W_GUIDE * pull;
|
||||
}
|
||||
if s >= 0.5 * contrast
|
||||
&& let (Some(t), Some(g)) = (tangent.get_mut(i), f.grad.get(i))
|
||||
{
|
||||
*t = [g[1], -g[0]];
|
||||
}
|
||||
}
|
||||
let mut dist = vec![f32::INFINITY; n];
|
||||
let mut from = vec![u8::MAX; n];
|
||||
*dist.get_mut(start)? = 0.0;
|
||||
let mut heap = BinaryHeap::new();
|
||||
// Costs are finite and non-negative, so their bit patterns sort like the values.
|
||||
heap.push(Reverse((0.0f32.to_bits(), start)));
|
||||
while let Some(Reverse((bits, i))) = heap.pop() {
|
||||
let d = f32::from_bits(bits);
|
||||
if d > dist.get(i).copied().unwrap_or(f32::INFINITY) {
|
||||
continue;
|
||||
}
|
||||
if i == goal {
|
||||
break;
|
||||
}
|
||||
let (x, y) = ((i % w) as i32, (i / w) as i32);
|
||||
let tp = tangent.get(i).copied().unwrap_or_default();
|
||||
for (k, &(dx, dy)) in DIRS.iter().enumerate() {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let j = ny as usize * w + nx as usize;
|
||||
let cost = node.get(j).copied().unwrap_or(f32::INFINITY);
|
||||
if !cost.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let len = if dx != 0 && dy != 0 { SQRT_2 } else { 1.0 };
|
||||
let tq = tangent.get(j).copied().unwrap_or_default();
|
||||
let nd = d + cost * len + W_DIRECTION * direction_cost(tp, tq, dx, dy, len);
|
||||
if let (Some(dj), Some(fj)) = (dist.get_mut(j), from.get_mut(j))
|
||||
&& nd < *dj
|
||||
{
|
||||
*dj = nd;
|
||||
*fj = k as u8;
|
||||
heap.push(Reverse((nd.to_bits(), j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dist.get(goal).is_some_and(|d| d.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut cells = vec![goal];
|
||||
let mut cur = goal;
|
||||
while cur != start && cells.len() <= n {
|
||||
let &(dx, dy) = DIRS.get(usize::from(*from.get(cur)?))?;
|
||||
let (x, y) = ((cur % w) as i32 - dx, (cur / w) as i32 - dy);
|
||||
if x < 0 || y < 0 {
|
||||
return None;
|
||||
}
|
||||
cur = y as usize * w + x as usize;
|
||||
cells.push(cur);
|
||||
}
|
||||
cells.reverse();
|
||||
Some(cells)
|
||||
}
|
||||
|
||||
/// The gradient-direction term of a link from a pixel with edge tangent `tp` to its neighbour at
|
||||
/// (`dx`, `dy`) with tangent `tq`: 0 running straight along an edge, up to 1 cutting across it or
|
||||
/// turning sharply (Mortensen & Barrett's f_D).
|
||||
fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32 {
|
||||
if tp == [0.0; 2] || tq == [0.0; 2] {
|
||||
return NEUTRAL_DIRECTION;
|
||||
}
|
||||
let mut l = [dx as f32 / len, dy as f32 / len];
|
||||
if tp[0] * l[0] + tp[1] * l[1] < 0.0 {
|
||||
l = [-l[0], -l[1]];
|
||||
}
|
||||
let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0);
|
||||
let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0);
|
||||
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
|
||||
}
|
||||
|
||||
/// The polyline `g` cut into pieces at most `piece` long; each piece starts where the last ended.
|
||||
fn split(g: &[[f64; 2]], piece: f64) -> Vec<Vec<[f64; 2]>> {
|
||||
let Some(&first) = g.first() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut cur = vec![first];
|
||||
let mut acc = 0.0;
|
||||
for w in g.windows(2) {
|
||||
let (mut a, b) = (w[0], w[1]);
|
||||
let mut seg = dist(a, b);
|
||||
while acc + seg > piece && seg > 0.0 {
|
||||
let t = ((piece - acc) / seg).clamp(0.0, 1.0);
|
||||
let m = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
cur.push(m);
|
||||
out.push(std::mem::replace(&mut cur, vec![m]));
|
||||
a = m;
|
||||
seg = dist(a, b);
|
||||
acc = 0.0;
|
||||
}
|
||||
cur.push(b);
|
||||
acc += seg;
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
/// Ramer–Douglas–Peucker: drops points within `tol` of the line through their neighbours that
|
||||
/// stay, keeping both ends.
|
||||
pub fn simplify(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
|
||||
let n = p.len();
|
||||
if n < 3 {
|
||||
return p.to_vec();
|
||||
}
|
||||
let mut keep = vec![false; n];
|
||||
keep[0] = true;
|
||||
keep[n - 1] = true;
|
||||
let tol2 = tol * tol;
|
||||
let mut stack = vec![(0usize, n - 1)];
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
if b <= a + 1 {
|
||||
continue;
|
||||
}
|
||||
let (Some(&pa), Some(&pb)) = (p.get(a), p.get(b)) else { continue };
|
||||
let mut far = (0.0, a);
|
||||
for (k, q) in p.iter().enumerate().take(b).skip(a + 1) {
|
||||
let d2 = dist2_segment(*q, pa, pb);
|
||||
if d2 > far.0 {
|
||||
far = (d2, k);
|
||||
}
|
||||
}
|
||||
if far.0 > tol2 {
|
||||
if let Some(k) = keep.get_mut(far.1) {
|
||||
*k = true;
|
||||
}
|
||||
stack.push((a, far.1));
|
||||
stack.push((far.1, b));
|
||||
}
|
||||
}
|
||||
p.iter().zip(&keep).filter(|(_, k)| **k).map(|(q, _)| *q).collect()
|
||||
}
|
||||
|
||||
/// Length of a polyline.
|
||||
pub fn length(p: &[[f64; 2]]) -> f64 {
|
||||
p.windows(2).map(|w| dist(w[0], w[1])).sum()
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
(a[0] - b[0]).hypot(a[1] - b[1])
|
||||
}
|
||||
|
||||
fn dist2_segment(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
let (vx, vy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l2 = vx * vx + vy * vy;
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (dx, dy) = (p[0] - (a[0] + t * vx), p[1] - (a[1] + t * vy));
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,195 +0,0 @@
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A procedural image as a fetch that counts the pixels it reads.
|
||||
fn image<'a>(f: impl Fn(i32, i32) -> [u8; 4] + 'a, read: &'a Cell<usize>) -> impl FnMut(Rect) -> Vec<[u8; 4]> + 'a {
|
||||
move |r: Rect| {
|
||||
read.set(read.get() + r.width() as usize * r.height() as usize);
|
||||
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
/// Distance from `p` to the polyline `g`.
|
||||
fn off_polyline(p: [f64; 2], g: &[[f64; 2]]) -> f64 {
|
||||
g.windows(2).map(|w| dist2_segment(p, w[0], w[1]).sqrt()).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snaps_to_the_edge_centre_within_the_width() {
|
||||
let read = Cell::new(0);
|
||||
// Black left of x = 20, white from it.
|
||||
let mut f = image(|x, _| grey(if x < 20 { 0 } else { 255 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 64, 32));
|
||||
let p = t.snap(&mut f, [14.3, 10.5], Settings::new(10.0, 0.1)).unwrap();
|
||||
assert!((p[0] - 20.0).abs() < 0.01 && (p[1] - 10.5).abs() < 0.01, "on the step, not half a pixel off: {p:?}");
|
||||
// From the other side too.
|
||||
let p = t.snap(&mut f, [27.0, 3.2], Settings::new(10.0, 0.1)).unwrap();
|
||||
assert!((p[0] - 20.0).abs() < 0.01, "{p:?}");
|
||||
// Out of reach: the point stays where it is.
|
||||
assert_eq!(t.snap(&mut f, [14.3, 10.5], Settings::new(3.0, 0.1)), Some([14.3, 10.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_sets_which_steps_are_edges() {
|
||||
let read = Cell::new(0);
|
||||
// A 5% step.
|
||||
let mut f = image(|x, _| grey(if x < 20 { 100 } else { 113 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 64, 32));
|
||||
assert_eq!(t.snap(&mut f, [14.3, 10.5], Settings::new(10.0, 0.10)), Some([14.3, 10.5]), "too faint at 10%");
|
||||
let p = t.snap(&mut f, [14.3, 10.5], Settings::new(10.0, 0.03)).unwrap();
|
||||
assert!((p[0] - 20.0).abs() < 0.01, "an edge at 3%: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snaps_to_the_most_prominent_edge() {
|
||||
let read = Cell::new(0);
|
||||
// A faint step at x = 12 and a strong one at x = 18.
|
||||
let mut f = image(|x, _| grey([0, 40, 255][usize::from(x >= 12) + usize::from(x >= 18)]), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 40, 20));
|
||||
let p = t.snap(&mut f, [13.0, 10.0], Settings::new(10.0, 0.1)).unwrap();
|
||||
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_follows_a_curved_edge() {
|
||||
let read = Cell::new(0);
|
||||
// A white disc of radius 30 at (50, 50) on black.
|
||||
let mut f = image(|x, y| grey(if (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0) < 30.0 { 255 } else { 0 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 100, 100));
|
||||
let s = Settings::default();
|
||||
let a = t.snap(&mut f, [50.0, 21.0], s).unwrap();
|
||||
let b = t.snap(&mut f, [79.0, 50.0], s).unwrap();
|
||||
// The pointer went straight from one to the other; the border bows out along the disc, ~9 px
|
||||
// from the chord at its middle.
|
||||
let path = t.trace(&mut f, a, b, &[], s);
|
||||
assert!(path.len() > 4, "{path:?}");
|
||||
assert_eq!((path[0], path[path.len() - 1]), (a, b));
|
||||
for p in &path {
|
||||
let r = (p[0] - 50.0).hypot(p[1] - 50.0);
|
||||
assert!((r - 30.0).abs() < 1.0, "{p:?} is {r:.2} from the centre: {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_edges_the_border_follows_the_pointer() {
|
||||
let read = Cell::new(0);
|
||||
let mut f = image(|_, _| grey(128), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 120, 80));
|
||||
let s = Settings::default();
|
||||
// A gentle curve: the border runs along it.
|
||||
let (a, b) = ([10.0, 40.0], [110.0, 40.0]);
|
||||
let guide: Vec<[f64; 2]> = (1..10).map(|i| [10.0 + f64::from(i) * 10.0, 40.0 - 15.0 * (f64::from(i) * std::f64::consts::PI / 10.0).sin()]).collect();
|
||||
let g: Vec<[f64; 2]> = std::iter::once(a).chain(guide.iter().copied()).chain([b]).collect();
|
||||
let path = t.trace(&mut f, a, b, &guide, s);
|
||||
for p in &path {
|
||||
assert!(off_polyline(*p, &g) < 2.0, "{p:?} strays from the pointer: {path:?}");
|
||||
}
|
||||
assert!(off_polyline([60.0, 25.0], &path) < 2.0, "the border went where the pointer went: {path:?}");
|
||||
// A sharp turn: the border may round it off, but never leaves the detection width.
|
||||
let guide = [[30.0, 70.0]];
|
||||
let path = t.trace(&mut f, [10.0, 10.0], [100.0, 75.0], &guide, s);
|
||||
for p in &path {
|
||||
assert!(off_polyline(*p, &[[10.0, 10.0], [30.0, 70.0], [100.0, 75.0]]) <= s.width, "{p:?}: {path:?}");
|
||||
}
|
||||
// A straight pointer path over nothing is one straight segment.
|
||||
assert_eq!(t.trace(&mut f, [10.0, 10.0], [110.0, 73.0], &[], s), vec![[10.0, 10.0], [110.0, 73.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corridor_keeps_the_border_off_edges_the_pointer_never_went_near() {
|
||||
let read = Cell::new(0);
|
||||
// A modest step at y = 20, under the pointer, and a much stronger one at y = 40.
|
||||
let mut f = image(|_, y| grey([60, 100, 255][usize::from(y >= 20) + usize::from(y >= 40)]), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 120, 60));
|
||||
let path = t.trace(&mut f, [10.0, 22.0], [110.0, 18.0], &[[60.0, 23.0]], Settings::new(10.0, 0.1));
|
||||
for p in &path {
|
||||
assert!((p[1] - 20.0).abs() <= 2.0 + 1e-9, "{p:?} left the edge under the pointer: {path:?}");
|
||||
}
|
||||
// Away from the ends it runs on the step itself.
|
||||
assert!(path.iter().filter(|p| p[0] > 25.0 && p[0] < 95.0).all(|p| (p[1] - 20.0).abs() < 0.01), "{path:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_guides_are_traced_in_pieces_along_the_edge() {
|
||||
let read = Cell::new(0);
|
||||
// A step at y = 30 across a wide image; the pointer wavers 4 px either side of it.
|
||||
let mut f = image(|_, y| grey(if y < 30 { 20 } else { 200 }), &read);
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 1000, 60));
|
||||
let guide: Vec<[f64; 2]> = (1..20).map(|i| [f64::from(i) * 50.0, if i % 2 == 0 { 26.0 } else { 34.0 }]).collect();
|
||||
let path = t.trace(&mut f, [5.0, 30.0], [995.0, 30.0], &guide, Settings::default());
|
||||
for p in &path {
|
||||
assert!((p[1] - 30.0).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
let len = length(&path);
|
||||
assert!((len - 990.0).abs() < 1.0, "a straight border, not a zigzag: {len}");
|
||||
// Collinear points are dropped.
|
||||
assert!(path.len() < 20, "{}", path.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_only_the_pixels_around_the_border() {
|
||||
let read = Cell::new(0);
|
||||
let mut f = image(|x, _| grey(if x < 3000 { 0 } else { 255 }), &read);
|
||||
// A 24 MP image.
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 6000, 4000));
|
||||
let s = Settings::default();
|
||||
let a = t.snap(&mut f, [2996.0, 2000.0], s).unwrap();
|
||||
let b = t.snap(&mut f, [3004.0, 2100.0], s).unwrap();
|
||||
let path = t.trace(&mut f, a, b, &[], s);
|
||||
assert!(path.iter().all(|p| (p[0] - 3000.0).abs() < 0.01), "{path:?}");
|
||||
let first = read.get();
|
||||
assert!(first <= 6 * 128 * 128, "read {first} px");
|
||||
assert_eq!(t.cached_pixels(), first);
|
||||
// The same area again comes from the cache.
|
||||
t.trace(&mut f, a, b, &[], s);
|
||||
assert_eq!(read.get(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
let read = Cell::new(0);
|
||||
let mut f = image(|x, y| grey(((x ^ y) & 255) as u8), &read);
|
||||
let s = Settings::default();
|
||||
// No image at all.
|
||||
let mut empty = Tracer::new(Rect::EMPTY);
|
||||
assert_eq!(empty.snap(&mut f, [1.0, 1.0], s), None);
|
||||
assert!(empty.trace(&mut f, [1.0, 1.0], [5.0, 5.0], &[], s).is_empty());
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 50, 40));
|
||||
// Not points.
|
||||
assert_eq!(t.snap(&mut f, [f64::NAN, 1.0], s), None);
|
||||
assert!(t.trace(&mut f, [f64::INFINITY, 1.0], [5.0, 5.0], &[], s).is_empty());
|
||||
// Far outside the image: kept inside it.
|
||||
let path = t.trace(&mut f, [-1e300, -1e9], [1e9, 1e300], &[[f64::NAN, 3.0], [1e12, -5.0]], s);
|
||||
assert!(path.iter().all(|p| (0.0..=50.0).contains(&p[0]) && (0.0..=40.0).contains(&p[1])), "{path:?}");
|
||||
// Nonsense settings are clamped, whichever way they arrive.
|
||||
for s in [Settings { width: f64::NAN, contrast: f32::NAN }, Settings { width: 1e300, contrast: -4.0 }, Settings { width: -3.0, contrast: 1e30 }] {
|
||||
t.snap(&mut f, [20.0, 20.0], s).unwrap();
|
||||
assert!(!t.trace(&mut f, [2.0, 2.0], [45.0, 30.0], &[[10.0, 35.0]], s).is_empty());
|
||||
}
|
||||
// A fetch that returns the wrong amount of pixels reads as transparent.
|
||||
let mut bad = |_: Rect| vec![[255u8; 4]; 3];
|
||||
let mut t = Tracer::new(Rect::new(0, 0, 300, 300));
|
||||
assert_eq!(t.snap(&mut bad, [150.0, 150.0], s), Some([150.0, 150.0]));
|
||||
let path = t.trace(&mut bad, [10.0, 10.0], [290.0, 280.0], &[], s);
|
||||
assert_eq!((path.first(), path.last()), (Some(&[10.0, 10.0]), Some(&[290.0, 280.0])));
|
||||
// The same point twice.
|
||||
assert_eq!(t.trace(&mut bad, [7.0, 7.0], [7.0, 7.0], &[], s), vec![[7.0, 7.0], [7.0, 7.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_simplify() {
|
||||
let g = [[0.0, 0.0], [600.0, 0.0], [600.0, 100.0]];
|
||||
let pieces = split(&g, 256.0);
|
||||
assert_eq!(pieces.len(), 3);
|
||||
assert_eq!(pieces[0], vec![[0.0, 0.0], [256.0, 0.0]]);
|
||||
assert_eq!(pieces[2].last(), Some(&[600.0, 100.0]));
|
||||
let total: f64 = pieces.iter().map(|p| length(p)).sum();
|
||||
assert!((total - 700.0).abs() < 1e-9);
|
||||
assert_eq!(simplify(&[[0.0, 0.0], [1.0, 0.1], [2.0, 0.0], [3.0, 5.0]], 0.3), vec![[0.0, 0.0], [2.0, 0.0], [3.0, 5.0]]);
|
||||
assert!(split(&[], 256.0).is_empty());
|
||||
}
|
||||
+22
-71
@@ -76,41 +76,39 @@ 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. Separable (rows, then
|
||||
/// columns), each pass a [`running_extreme`], so the cost per pixel does not grow with the radius.
|
||||
/// Minimum (spreads dark/transparent areas) or Maximum over a square.
|
||||
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);
|
||||
}
|
||||
let k = (2 * r + 1) as usize;
|
||||
// Rows: the extreme over x - r..=x + r, for every row the column pass needs.
|
||||
// Separable: rows then columns.
|
||||
let win = Rect::new(out.x0, out.y0 - r, out.x1, out.y1 + r);
|
||||
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());
|
||||
let ww = win.width() as usize;
|
||||
let mut tmp = vec![0.0f32; ww * win.height() as usize * n];
|
||||
for y in win.y0..win.y1 {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,50 +137,3 @@ 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,13 +31,10 @@ 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, &|| false);
|
||||
solve_rec(w, h, unknown, v, 0);
|
||||
}
|
||||
|
||||
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize, stop: &(dyn Fn() -> bool + Sync)) {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize) {
|
||||
let n_unknown = unknown.iter().filter(|u| **u).count();
|
||||
if n_unknown == 0 {
|
||||
return;
|
||||
@@ -67,7 +64,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
solve_rec(cw, chh, &cu, &mut cv, depth + 1, stop);
|
||||
solve_rec(cw, chh, &cu, &mut cv, depth + 1);
|
||||
// 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);
|
||||
@@ -87,7 +84,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, stop);
|
||||
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA);
|
||||
} else {
|
||||
let mean = known.iter().sum::<f32>() / known.len() as f32;
|
||||
for (x, u) in v.iter_mut().zip(unknown) {
|
||||
@@ -95,12 +92,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, stop);
|
||||
sor(w, h, unknown, v, 2000, 1.85);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, stop: &(dyn Fn() -> bool + Sync)) {
|
||||
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32) {
|
||||
// Precompute the unknown pixels and which neighbours exist.
|
||||
let cells: Vec<(u32, u8)> = (0..w * h)
|
||||
.filter(|&i| unknown[i])
|
||||
@@ -111,9 +108,6 @@ 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;
|
||||
@@ -235,28 +229,6 @@ 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::*;
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+35
-233
@@ -1,6 +1,6 @@
|
||||
//! High-quality separable resampling of surfaces (Image Size).
|
||||
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,30 +68,18 @@ impl Resample {
|
||||
struct Taps {
|
||||
start: i32,
|
||||
weights: Vec<f32>,
|
||||
/// Total weight of taps outside the source's content, which all read the default pixel.
|
||||
outside: f32,
|
||||
}
|
||||
|
||||
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
|
||||
/// via `u = (o + 0.5) / scale - 0.5`. With `edge` (`[first, last]` source index), taps outside it
|
||||
/// read the nearest sample inside it: the image repeats its edge instead of fading out.
|
||||
/// Taps outside `content` (`[first, last]`) all read the default pixel, so they are folded into
|
||||
/// `outside` instead of being read: the read window never grows past the content, however small
|
||||
/// the scale (#713).
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>, content: (i32, i32)) -> Vec<Taps> {
|
||||
/// via `u = (o + 0.5) / scale - 0.5`.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> 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 {
|
||||
let i = clamp((u + 0.5).floor() as i32);
|
||||
return if (content.0..=content.1).contains(&i) {
|
||||
Taps { start: i, weights: vec![1.0], outside: 0.0 }
|
||||
} else {
|
||||
Taps { start: content.0, weights: Vec::new(), outside: 1.0 }
|
||||
};
|
||||
return Taps { start: (u + 0.5).floor() as i32, weights: vec![1.0] };
|
||||
}
|
||||
let lo = (u - support).ceil() as i32;
|
||||
let hi = (u + support).floor() as i32;
|
||||
@@ -102,22 +90,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i
|
||||
*v /= s;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Keep the part inside the content; the rest reads the default pixel.
|
||||
let (a, b) = (start.max(content.0), (start + folded.len() as i32 - 1).min(content.1));
|
||||
if a > b {
|
||||
return Taps { start: content.0, weights: Vec::new(), outside: folded.iter().sum() };
|
||||
}
|
||||
let (i0, i1) = ((a - start) as usize, (b - start) as usize + 1);
|
||||
let outside = folded.get(..i0).unwrap_or(&[]).iter().chain(folded.get(i1..).unwrap_or(&[])).sum();
|
||||
Taps { start: a, weights: folded.get(i0..i1).unwrap_or(&[]).to_vec(), outside }
|
||||
Taps { start: lo, weights: w.into_iter().map(|v| v as f32).collect() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -133,26 +106,6 @@ 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))
|
||||
}
|
||||
|
||||
/// Samples one source read may hold (64 MB of `f32`); larger windows are read in row chunks.
|
||||
const READ_BUDGET: usize = 1 << 24;
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
resize_with_budget(s, sx, sy, filter, edge, READ_BUDGET)
|
||||
}
|
||||
|
||||
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>, budget: usize) -> Surface {
|
||||
let fmt = s.format();
|
||||
let mut out = Surface::with_default(fmt, &s.default_pixel());
|
||||
let src = s.content_bounds();
|
||||
@@ -162,101 +115,47 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let alpha = fmt.alpha;
|
||||
// With `edge`, every tap is clamped into the edge area, which holds the content.
|
||||
let area = edge.unwrap_or(src);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
|
||||
let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0);
|
||||
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo);
|
||||
let mut def = s.default_pixel();
|
||||
premultiply(&mut def, n, alpha);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter);
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter);
|
||||
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;
|
||||
const BAND: usize = 64;
|
||||
let bands: Vec<(usize, usize)> = (0..vy.len()).step_by(BAND).map(|b| (b, (b + BAND).min(vy.len()))).collect();
|
||||
// Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples.
|
||||
let horizontal = |row: &[f32], out: &mut [f32]| {
|
||||
for (t, d) in hx.iter().zip(out.chunks_exact_mut(n)) {
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let sx_ = (t.start + k as i32 - x_lo) as usize;
|
||||
if let Some(sp) = row.get(sx_ * n..(sx_ + 1) * n) {
|
||||
let run = |&(b0, b1): &(usize, usize)| -> (Rect, Vec<f32>) {
|
||||
let y_lo = vy[b0..b1].iter().map(|t| t.start).min().unwrap_or(0);
|
||||
let y_hi = vy[b0..b1].iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(0);
|
||||
let read = Rect::new(x_lo, y_lo, x_hi, y_hi);
|
||||
let mut rows = s.read_region(read);
|
||||
premultiply(&mut rows, n, alpha);
|
||||
let rw = read.width() as usize;
|
||||
// Horizontal pass: every source row in the band → dw samples.
|
||||
let nrows = read.height() as usize;
|
||||
let mut tmp = vec![0.0f32; nrows * dw * n];
|
||||
for r in 0..nrows {
|
||||
let row = &rows[r * rw * n..(r + 1) * rw * n];
|
||||
for (ox, t) in hx.iter().enumerate() {
|
||||
let d = &mut tmp[(r * dw + ox) * n..(r * dw + ox + 1) * n];
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let sx_ = (t.start + k as i32 - x_lo) as usize;
|
||||
let sp = &row[sx_ * n..(sx_ + 1) * n];
|
||||
for c in 0..n {
|
||||
d[c] += sp[c] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..n {
|
||||
d[c] += def[c] * t.outside;
|
||||
}
|
||||
}
|
||||
};
|
||||
// A source row of nothing but the default pixel, after the horizontal pass.
|
||||
let def_row = {
|
||||
let row: Vec<f32> = def.iter().copied().cycle().take((x_hi - x_lo) as usize * n).collect();
|
||||
let mut out = vec![0.0f32; dw * n];
|
||||
horizontal(&row, &mut out);
|
||||
out
|
||||
};
|
||||
// At most one tile row per read anyway, so this always fits an `i32`.
|
||||
let rows_per_read = (budget / ((x_hi - x_lo).max(1) as usize * n).max(dw * n)).clamp(1, TILE_SIZE as usize) as i32;
|
||||
let run = |&(b0, b1): &(usize, usize)| -> (Rect, Vec<f32>) {
|
||||
let band = vy.get(b0..b1).unwrap_or(&[]);
|
||||
let y_lo = band.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.y0);
|
||||
let y_hi = band.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.y0).max(y_lo);
|
||||
let mut res = vec![0.0f32; band.len() * dw * n];
|
||||
// Source rows stream through in bounded reads and go straight into the output rows that
|
||||
// use them, so memory is the read budget plus this band's output, whatever the scale or
|
||||
// the source size (#713). In ascending row order, as one read of the whole window would.
|
||||
let add = |res: &mut [f32], y: i32, row: &[f32]| {
|
||||
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
|
||||
if let Some(w) = t.weights.get((y - t.start) as usize).filter(|_| y >= t.start) {
|
||||
for (dv, sv) in d.iter_mut().zip(row) {
|
||||
*dv += sv * w;
|
||||
}
|
||||
// Vertical pass.
|
||||
let mut res = vec![0.0f32; (b1 - b0) * dw * n];
|
||||
for (oy, t) in vy[b0..b1].iter().enumerate() {
|
||||
let d = &mut res[oy * dw * n..(oy + 1) * dw * n];
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let r = (t.start + k as i32 - y_lo) as usize;
|
||||
let sp = &tmp[r * dw * n..(r + 1) * dw * n];
|
||||
for (dv, sv) in d.iter_mut().zip(sp) {
|
||||
*dv += sv * w;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
let mut hrows = Vec::new();
|
||||
let mut y = y_lo;
|
||||
while y < y_hi {
|
||||
let y1 = ((y.div_euclid(TILE_SIZE) + 1) * TILE_SIZE).min(y_hi);
|
||||
if s.has_tiles_in(Rect::new(x_lo, y, x_hi, y1)) {
|
||||
let mut c0 = y;
|
||||
while c0 < y1 {
|
||||
let c1 = (c0 + rows_per_read).min(y1);
|
||||
let read = Rect::new(x_lo, c0, x_hi, c1);
|
||||
s.read_region_into(read, &mut rows);
|
||||
premultiply(&mut rows, n, alpha);
|
||||
hrows.clear();
|
||||
hrows.resize((c1 - c0) as usize * dw * n, 0.0);
|
||||
let rw = read.width() as usize * n;
|
||||
for (row, out) in rows.chunks_exact(rw.max(1)).zip(hrows.chunks_exact_mut(dw * n)) {
|
||||
horizontal(row, out);
|
||||
}
|
||||
for (r, h) in (c0..c1).zip(hrows.chunks_exact(dw * n)) {
|
||||
add(&mut res, r, h);
|
||||
}
|
||||
c0 = c1;
|
||||
}
|
||||
} else {
|
||||
// Nothing stored in this tile row: every row in it is the default row, so each
|
||||
// output row takes it once, with the summed weight of those rows.
|
||||
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
|
||||
let (a, b) = ((y - t.start).max(0) as usize, (y1 - t.start).max(0) as usize);
|
||||
let w: f32 = t.weights.get(a.min(t.weights.len())..b.min(t.weights.len())).unwrap_or(&[]).iter().sum();
|
||||
if w != 0.0 {
|
||||
for (dv, sv) in d.iter_mut().zip(&def_row) {
|
||||
*dv += sv * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
y = y1;
|
||||
}
|
||||
for (t, d) in band.iter().zip(res.chunks_exact_mut(dw * n)) {
|
||||
for (dv, sv) in d.iter_mut().zip(&def_row) {
|
||||
*dv += sv * t.outside;
|
||||
}
|
||||
}
|
||||
if alpha {
|
||||
for px in res.chunks_exact_mut(n) {
|
||||
@@ -369,103 +268,6 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two tiles in opposite corners of a 100000 px square: the content bounds are huge but
|
||||
/// almost nothing is stored. Shrinking it to one pixel must neither allocate a window
|
||||
/// proportional to the scale (#713) nor read the empty middle.
|
||||
#[test]
|
||||
fn tiny_target_of_a_huge_sparse_source() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||
s.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 1.0, 1.0, 1.0]);
|
||||
s.fill_rect(Rect::new(99_840, 99_840, 100_096, 100_096), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let k = 1.0 / 100_096.0;
|
||||
let o = resize_surface(&s, k, k, Resample::Bicubic);
|
||||
assert_eq!(o.content_bounds(), Rect::new(0, 0, 1, 1));
|
||||
let p = o.pixel(0, 0);
|
||||
// Two 256² tiles out of 100096²: nearly transparent, and white where it isn't.
|
||||
assert!(p[3] >= 0.0 && p[3] < 1e-3 && p.iter().all(|v| v.is_finite()), "{p:?}");
|
||||
// Squashed to one row but kept wide: the whole height feeds one band of output rows,
|
||||
// which must not hold a full-height intermediate.
|
||||
let o = resize_surface(&s, 1000.0 / 100_096.0, k, Resample::Bicubic);
|
||||
let b = o.content_bounds();
|
||||
assert!(b.height() == 1 && (1000..=1001).contains(&b.width()), "{b:?}");
|
||||
assert!(o.pixel(0, 0)[3] > 0.0 && o.pixel(500, 0)[3] == 0.0, "{:?} {:?}", o.pixel(0, 0), o.pixel(500, 0));
|
||||
}
|
||||
|
||||
/// Tile rows with nothing stored are not read; they count as rows of the default pixel,
|
||||
/// exactly as reading them would (and within rounding for a non-zero default).
|
||||
#[test]
|
||||
fn skipped_empty_rows_match_reading_them() {
|
||||
for (fmt, def, tol) in [(PixelFormat::RGBA32F, vec![0.0, 0.0, 0.0, 0.0], 0.0), (PixelFormat::GRAY8, vec![1.0], 1e-6)] {
|
||||
let mut sparse = Surface::with_default(fmt, &def);
|
||||
sparse.fill_rect(Rect::new(0, 0, 300, 40), &vec![0.5; def.len()]);
|
||||
sparse.fill_rect(Rect::new(100, 1500, 400, 1540), &vec![0.25; def.len()]);
|
||||
// The same pixels with the empty rows stored as tiles of the default pixel.
|
||||
let mut dense = sparse.clone();
|
||||
dense.fill_rect(Rect::new(0, 40, 400, 1500), &def);
|
||||
assert!(dense.tile_count() > sparse.tile_count());
|
||||
for f in [Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
for (kx, ky) in [(0.013, 0.013), (0.37, 0.37), (1.0, 0.01)] {
|
||||
let (a, b) = (resize_surface(&sparse, kx, ky, f), resize_surface(&dense, kx, ky, f));
|
||||
let r = a.content_bounds().union(&b.content_bounds());
|
||||
let d = a.read_region(r).iter().zip(b.read_region(r)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
|
||||
assert!(d <= tol, "{fmt:?} {f:?} {kx} {ky}: {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reading the source in row chunks gives exactly what one read of the whole window gives.
|
||||
#[test]
|
||||
fn chunked_reads_match_a_single_read() {
|
||||
let mut s = ramp(SampleType::F32);
|
||||
s.fill_rect(Rect::new(600, 700, 640, 720), &[0.3, 0.9, 0.1, 0.5]);
|
||||
let mut mask = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
|
||||
mask.fill_rect(Rect::new(0, 0, 40, 30), &[0.0]);
|
||||
mask.fill_rect(Rect::new(500, 900, 530, 940), &[0.25]);
|
||||
for src in [&s, &mask] {
|
||||
for f in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
for k in [0.013, 0.37, 1.6] {
|
||||
let whole = resize_with_budget(src, k, k, f, None, usize::MAX);
|
||||
let chunked = resize_with_budget(src, k, k, f, None, 1);
|
||||
let r = whole.content_bounds().union(&chunked.content_bounds());
|
||||
assert_eq!(whole.read_region(r), chunked.read_region(r), "{f:?} {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_keep_default_pixel() {
|
||||
let fmt = PixelFormat::GRAY8;
|
||||
|
||||
+84
-513
@@ -11,8 +11,6 @@
|
||||
//! 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;
|
||||
|
||||
@@ -21,206 +19,69 @@ 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];
|
||||
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);
|
||||
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;
|
||||
}
|
||||
};
|
||||
#[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> {
|
||||
if w == 0 || h == 0 || energy.len() < w * h {
|
||||
return vec![0; h];
|
||||
}
|
||||
Dp::default().seam(w, w, h, energy)
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
seam
|
||||
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
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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
|
||||
}
|
||||
|
||||
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 remove_seam_1(w: usize, h: usize, v: &[f32], seam: &[usize]) -> Vec<f32> {
|
||||
remove_seam(w, h, 1, v, seam)
|
||||
}
|
||||
|
||||
fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
|
||||
@@ -236,25 +97,29 @@ 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 Ok(img.to_vec());
|
||||
return img.to_vec();
|
||||
}
|
||||
let seams = new_w.abs_diff(w).max(1) as f32;
|
||||
if new_w < w {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
return Ok(carver.into_pixels());
|
||||
};
|
||||
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;
|
||||
}
|
||||
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);
|
||||
@@ -262,18 +127,23 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
|
||||
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 carver = Carver::new(cw, h, ch, cur.clone(), prot.clone(), true);
|
||||
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 carver.cw == 0 {
|
||||
if tw <= 1 {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
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_1(tw, h, &p, &seam));
|
||||
tw -= 1;
|
||||
}
|
||||
let added = dup[0].len();
|
||||
if added == 0 {
|
||||
@@ -307,44 +177,21 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
|
||||
prot = nprot;
|
||||
cw = nw;
|
||||
}
|
||||
Ok(cur)
|
||||
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));
|
||||
// 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))?;
|
||||
let wide = carve_width(w, h, ch, img, protect, new_w);
|
||||
if new_h == h {
|
||||
return Ok(wide);
|
||||
return 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 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))
|
||||
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
|
||||
transpose(new_h, new_w, ch, &tall)
|
||||
}
|
||||
|
||||
/// Plain bilinear resize (the non-content-aware part of the Amount blend).
|
||||
@@ -394,211 +241,6 @@ 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];
|
||||
@@ -673,77 +315,6 @@ 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];
|
||||
|
||||
@@ -34,9 +34,7 @@ fn run(s: &Surface, p: &FilterParams) -> Surface {
|
||||
fn all_filters() -> Vec<FilterParams> {
|
||||
vec![
|
||||
FilterParams::GaussianBlur { radius: 2.5 },
|
||||
FilterParams::GaussianBlur { radius: 9.0 },
|
||||
FilterParams::BoxBlur { radius: 2.0 },
|
||||
FilterParams::BoxBlur { radius: 6.0 },
|
||||
FilterParams::MotionBlur { angle: 30.0, distance: 7.0 },
|
||||
FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, center_x: 0.5, center_y: 0.5 },
|
||||
FilterParams::RadialBlur { amount: 30.0, method: RadialMethod::Zoom, center_x: 0.3, center_y: 0.6 },
|
||||
|
||||
@@ -74,7 +74,6 @@ 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 },
|
||||
@@ -667,28 +666,3 @@ 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,9 +343,7 @@ 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;
|
||||
// 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);
|
||||
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(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;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
//! 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,19 +46,6 @@ 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,34 +103,19 @@ 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, escaped: false }
|
||||
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096 }
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
let bytes = encode_with_limit(result, self.remaining.saturating_sub(1))?;
|
||||
self.remaining = self.remaining.saturating_sub(bytes.len() + 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -176,23 +161,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_cannot_retain_more_than_the_aggregate_reply_budget() {
|
||||
let mut budget = BatchReplyBudget { remaining: 10, escaped: false };
|
||||
let mut budget = BatchReplyBudget { remaining: 10 };
|
||||
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 = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let bytes = std::fs::read(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,20 +4,14 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_engine::{Session, command_specs, file_cmds};
|
||||
use photocraft_engine::{Session, command_specs};
|
||||
use photocraft_format::PcraftWriter;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::workspace::{authorize_engine_command, authorize_engine_step};
|
||||
use crate::workspace::authorize_engine_command;
|
||||
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,
|
||||
@@ -34,7 +28,7 @@ pub struct Headless {
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
untrusted(Filesystem::Denied)
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Denied }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,23 +39,14 @@ impl Headless {
|
||||
}
|
||||
|
||||
/// Create a session for an explicit local CLI invocation. The CLI caller,
|
||||
/// not a remote automation client, supplies these host paths. Nested
|
||||
/// `actions.play` steps are not re-checked: this caller is already trusted.
|
||||
/// not a remote automation client, supplies these host paths.
|
||||
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 {
|
||||
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();
|
||||
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Workspace(workspace) }
|
||||
}
|
||||
|
||||
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
|
||||
@@ -75,7 +60,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 mut o = match &self.filesystem {
|
||||
let 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) => {
|
||||
@@ -84,14 +69,7 @@ impl Headless {
|
||||
files::open_bytes(name, &bytes)?
|
||||
}
|
||||
};
|
||||
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 index = self.session.add_document(o.document, Some(requested.to_string()));
|
||||
let d = &self.session.documents()[index];
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
@@ -113,18 +91,7 @@ impl Headless {
|
||||
};
|
||||
let target: PathBuf = match (path, stored_path.as_deref()) {
|
||||
(Some(p), _) => p.to_path_buf(),
|
||||
// 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, Some(p)) => PathBuf::from(p),
|
||||
(None, None) => {
|
||||
return Err(AutomationError::BadRequest("document has no path; pass `path`".into()));
|
||||
}
|
||||
@@ -218,25 +185,10 @@ 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)?;
|
||||
}
|
||||
// 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}),
|
||||
})
|
||||
Ok(self.session.execute(id, params)?)
|
||||
}
|
||||
}
|
||||
+22
-168
@@ -5,13 +5,12 @@
|
||||
//! loopback TCP port.
|
||||
//!
|
||||
//! Methods (camelCase params):
|
||||
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
|
||||
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
|
||||
//! - `engine.execute {command, params?}` / `engine.commands {filter?}`
|
||||
//! - `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?, wait?} | {method, params?}], stopOnError?}`
|
||||
//! - `batch {steps: [{command, params?} | {method, params?}], stopOnError?}`
|
||||
//! - `methods`: this list.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
@@ -24,16 +23,13 @@ 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, discard_rest_of_line,
|
||||
read_bounded_line,
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, 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",
|
||||
@@ -62,17 +58,12 @@ 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`"))?;
|
||||
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)
|
||||
self.command_run(id, p.get("params").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
// 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) {
|
||||
@@ -97,12 +88,9 @@ impl Headless {
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), ..Default::default() };
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
let path = str_of(&p, "path").map(PathBuf::from);
|
||||
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
|
||||
@@ -138,16 +126,10 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `steps` in order. Each step is `{command, params?, wait?}` (an engine command) or
|
||||
/// Run `steps` in order. Each step is `{command, params?}` (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())));
|
||||
@@ -155,12 +137,11 @@ 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") {
|
||||
// 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)
|
||||
self.command_run(c, params)
|
||||
} else if let Some(m) = str_of(s, "method") {
|
||||
if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) }
|
||||
} else {
|
||||
@@ -211,24 +192,25 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored.
|
||||
pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write) -> std::io::Result<()> {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
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),
|
||||
};
|
||||
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);
|
||||
write_reply(&mut w, &reply)?;
|
||||
w.flush()?;
|
||||
if unread_rest {
|
||||
discard_rest_of_line(&mut r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,96 +378,6 @@ 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();
|
||||
@@ -512,28 +404,6 @@ 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()));
|
||||
@@ -561,22 +431,6 @@ 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,43 +34,19 @@ 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 bytes = Vec::new();
|
||||
let n = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64).read_until(b'\n', &mut bytes)?;
|
||||
let mut limited = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64);
|
||||
let n = limited.read_line(line)?;
|
||||
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];
|
||||
@@ -259,25 +235,6 @@ 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]
|
||||
|
||||
+25
-142
@@ -72,20 +72,15 @@ 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 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.
|
||||
/// The extension selects the format. Omit to save to the document's own relative path.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// JPEG or WebP quality 1..100. A WebP saved with a quality is lossy; without one it is lossless.
|
||||
/// 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>,
|
||||
}
|
||||
@@ -117,51 +112,29 @@ pub struct ListParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RunParams {
|
||||
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussianBlur`.
|
||||
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussian`.
|
||||
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"}}, …]`. 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.
|
||||
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`.
|
||||
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}` (also `alt`, `command`, `ctrl`, `space`).
|
||||
/// Modifier keys, e.g. `{"shift":true}`.
|
||||
#[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)]
|
||||
@@ -172,10 +145,7 @@ pub struct MenuParams {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct UiSetParams {
|
||||
/// 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.
|
||||
/// Fields accepted by the control method `ui.set` (tool, panels, zoom, center, dark).
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -282,7 +252,6 @@ 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
|
||||
@@ -312,24 +281,22 @@ 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 },
|
||||
..Default::default()
|
||||
};
|
||||
let opts =
|
||||
photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 } };
|
||||
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 {
|
||||
@@ -405,11 +372,11 @@ impl PhotocraftMcp {
|
||||
if let Some(v) = p.name {
|
||||
params.insert("name".into(), v.into());
|
||||
}
|
||||
self.run_command("file.new".into(), Value::Object(params), true).await
|
||||
self.run_command("file.new".into(), Value::Object(params)).await
|
||||
}
|
||||
|
||||
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
|
||||
(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.")]
|
||||
(psd, png, jpg, tif, webp, exr, …) export. 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
|
||||
}
|
||||
@@ -495,23 +462,9 @@ 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. \
|
||||
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).")]
|
||||
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result.")]
|
||||
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
|
||||
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
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))).await
|
||||
}
|
||||
|
||||
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
|
||||
@@ -521,11 +474,9 @@ 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!({})), "wait": s.wait.unwrap_or(true)})).collect();
|
||||
let steps: Vec<Value> = p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})).collect();
|
||||
let args = json!({"steps": steps, "stopOnError": stop});
|
||||
// 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 {
|
||||
if let Some(r) = self.headless_op(move |h| h.batch(&args)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
@@ -533,7 +484,7 @@ impl PhotocraftMcp {
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
let mut failed = 0;
|
||||
let mut reply_budget = BatchReplyBudget::escaped();
|
||||
let mut reply_budget = BatchReplyBudget::default();
|
||||
for s in &steps {
|
||||
let response = b.call("engine.execute", s.clone()).await;
|
||||
let was_error = response.is_err();
|
||||
@@ -559,9 +510,7 @@ impl PhotocraftMcp {
|
||||
|
||||
// ----- live-GUI tools (bridge mode) -----
|
||||
|
||||
#[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`."
|
||||
)]
|
||||
#[tool(description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree).")]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
|
||||
@@ -577,9 +526,7 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[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."
|
||||
)]
|
||||
#[tool(description = "Bridge mode: send pointer events (document coordinates) to the active tool.")]
|
||||
async fn ui_pointer(&self, Parameters(p): Parameters<PointerParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => {
|
||||
@@ -587,9 +534,6 @@ 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"),
|
||||
@@ -604,9 +548,7 @@ impl PhotocraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bridge mode: change UI state (tool, panels, dock, zoom, center, fit, theme, brush settings; see `fields`). Unknown fields are an error."
|
||||
)]
|
||||
#[tool(description = "Bridge mode: change UI state (tool, panels, zoom, center, dark).")]
|
||||
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),
|
||||
@@ -628,27 +570,15 @@ fn bridge_only_headless(name: &str) -> Result<CallToolResult, McpError> {
|
||||
}
|
||||
|
||||
impl PhotocraftMcp {
|
||||
async fn run_command(&self, id: String, params: Value, wait: bool) -> Result<CallToolResult, McpError> {
|
||||
async fn run_command(&self, id: String, params: Value) -> Result<CallToolResult, McpError> {
|
||||
let (id2, params2) = (id.clone(), params.clone());
|
||||
if let Some(r) = self.headless_op(move |h| h.command_start(&id2, params2, wait)).await {
|
||||
if let Some(r) = self.headless_op(move |h| h.command_run(&id2, params2)).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, "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)
|
||||
to_result(b.call("engine.execute", json!({"command": id, "params": params})).await)
|
||||
}
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
@@ -660,11 +590,9 @@ impl PhotocraftMcp {
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
let path = p.path.map(PathBuf::from);
|
||||
h.save(p.index, path.as_deref(), p.format.as_deref(), &opts)
|
||||
@@ -704,7 +632,6 @@ 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());
|
||||
}
|
||||
@@ -717,48 +644,4 @@ 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::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -46,8 +46,9 @@ impl AuthorizedWorkspace {
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
// Bounded reads, and a clear error for a file larger than memory (#375).
|
||||
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(|e| file_error("read", path, e))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
@@ -153,7 +154,6 @@ 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"
|
||||
@@ -178,8 +178,8 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
||||
| "layer.videoLayers.newVideoLayerFromFile"
|
||||
| "layer.videoLayers.replaceFootage"
|
||||
| "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,23 +201,11 @@ 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" | "image.applyDataSet" => &["path"],
|
||||
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
|
||||
"layer.quickExportAsPng" | "layer.exportAs" => &["path"],
|
||||
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
|
||||
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
|
||||
_ => &[],
|
||||
@@ -244,29 +232,16 @@ 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(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)));
|
||||
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."))
|
||||
});
|
||||
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('\\')
|
||||
@@ -432,212 +407,22 @@ 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",
|
||||
"measurementLog.export",
|
||||
"layer.videoLayers.reloadFrame",
|
||||
"image.applyDataSet",
|
||||
"edit.colorSettings",
|
||||
] {
|
||||
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());
|
||||
// An allowed command can't reach a denied one by running it on its own behalf.
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.execute("file.new", serde_json::json!({"width": 4, "height": 4})).unwrap();
|
||||
session.authorize = Some(authorize_desktop_engine_step);
|
||||
let params = serde_json::json!({"to": "grayscale"});
|
||||
assert!(authorize_desktop_engine_command("file.automate.conditionalModeChange", ¶ms).is_ok());
|
||||
assert!(session.execute("file.automate.conditionalModeChange", params).is_err());
|
||||
assert_eq!(session.active().unwrap().doc.mode, photocraft_engine::doc::ColorMode::Rgb);
|
||||
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_data_set_is_judged_by_the_values_it_applies() {
|
||||
let mut headless = crate::Headless::new();
|
||||
headless.command_run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap();
|
||||
let layer = headless.command_run("layer.new.layer", serde_json::json!({})).unwrap()["layer"].clone();
|
||||
let defs = [
|
||||
serde_json::json!({"name": "shown", "layer": layer, "type": "visibility"}),
|
||||
serde_json::json!({"name": "photo", "layer": layer, "type": "pixelReplacement"}),
|
||||
];
|
||||
headless.command_run("image.variables.define", serde_json::json!({"defs": defs})).unwrap();
|
||||
let sets = serde_json::json!({"dataSets": [
|
||||
{"name": "hidden", "values": [{"variable": "shown", "kind": "visibility", "value": false}]},
|
||||
{"name": "swap", "values": [
|
||||
{"variable": "shown", "kind": "visibility", "value": false},
|
||||
{"variable": "photo", "kind": "pixels", "value": "/outside/photo.png"},
|
||||
]},
|
||||
]});
|
||||
headless.command_run("image.variables.dataSets", sets).unwrap();
|
||||
let visible = |headless: &crate::Headless| headless.session.active().unwrap().doc.layers.iter().all(|l| l.visible);
|
||||
let refused = headless.command_run("image.applyDataSet", serde_json::json!({"name": "swap"})).unwrap_err();
|
||||
assert!(refused.to_string().contains("ambient filesystem paths"), "{refused}");
|
||||
assert!(visible(&headless), "a refused data set applies none of its values");
|
||||
assert!(headless.command_run("image.applyDataSet", serde_json::json!({"name": "hidden"})).is_ok());
|
||||
assert!(!visible(&headless));
|
||||
}
|
||||
|
||||
#[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.moveAnchors"
|
||||
| "path.moveHandle"
|
||||
| "path.bendSegment"
|
||||
| "path.convertPoint"
|
||||
| "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());
|
||||
}
|
||||
}
|
||||
@@ -21,27 +21,15 @@ impl ClientHandler for Client {
|
||||
|
||||
type Conn = RunningService<RoleClient, Client>;
|
||||
|
||||
/// The server side of a test connection. It owns the headless session and its workspace,
|
||||
/// which holds open directory handles on the test folder.
|
||||
type Server = tokio::task::JoinHandle<()>;
|
||||
|
||||
async fn connect(root: &std::path::Path) -> (Conn, Server) {
|
||||
async fn connect(root: &std::path::Path) -> Conn {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
let server = tokio::spawn(async move {
|
||||
tokio::spawn(async move {
|
||||
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
(Client.serve(c).await.expect("client init"), server)
|
||||
}
|
||||
|
||||
/// Closes the client and waits for the server task to end, so the session and its workspace
|
||||
/// (with their open directory handles) are dropped before the test removes its folder. Windows
|
||||
/// refuses to delete a directory that still has open handles (os error 32).
|
||||
async fn shutdown(c: Conn, server: Server) {
|
||||
c.cancel().await.unwrap();
|
||||
server.await.unwrap();
|
||||
Client.serve(c).await.expect("client init")
|
||||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
@@ -108,7 +96,7 @@ fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||||
async fn agent_completes_ten_scripted_tasks() {
|
||||
let dir = tmp("tasks");
|
||||
let png = gradient_png(&dir);
|
||||
let (c, server) = connect(&dir).await;
|
||||
let c = connect(&dir).await;
|
||||
|
||||
// 1. Make a title card: type layer with a drop shadow, exported as PNG.
|
||||
tool(&c, "doc_new", json!({"width": 200, "height": 120, "background": "white"})).await;
|
||||
@@ -223,122 +211,6 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
assert_eq!((doc["mode"].as_str(), doc["width"].as_u64()), (Some("Cmyk"), Some(45)), "task 10");
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 4, "task 10");
|
||||
|
||||
shutdown(c, server).await;
|
||||
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, server) = 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;
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
async fn connect_headless(headless: photocraft_automation::Headless) -> (Conn, Server) {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let server = 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"), server)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_records_and_replays_an_action() {
|
||||
let dir = tmp("actions");
|
||||
let (c, server) = 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:?}");
|
||||
|
||||
shutdown(c, server).await;
|
||||
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, server) = 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}");
|
||||
shutdown(c, server).await;
|
||||
c.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -169,39 +169,6 @@ 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;
|
||||
@@ -287,86 +254,6 @@ 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");
|
||||
@@ -406,10 +293,6 @@ 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 {
|
||||
@@ -434,17 +317,14 @@ async fn fake_app_with_screenshot(screenshot_png: Option<Vec<u8>>) -> (String, t
|
||||
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"], "wait": req["params"]["wait"]}})
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"]}})
|
||||
}
|
||||
"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 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 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 png = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
json!({"id": id, "ok": true, "result": {"mimeType": "image/png", "base64": png}})
|
||||
}
|
||||
@@ -488,44 +368,6 @@ 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());
|
||||
@@ -579,97 +421,6 @@ 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,9 +22,6 @@ 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,15 +34,6 @@ 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 {
|
||||
@@ -65,9 +56,11 @@ impl LutFile {
|
||||
}
|
||||
|
||||
fn check(self) -> Result<Self, LutError> {
|
||||
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.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));
|
||||
}
|
||||
if self.data.iter().any(|v| !v.is_finite()) {
|
||||
return err("non-finite LUT value");
|
||||
@@ -135,11 +128,7 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
||||
if size3 > 0 {
|
||||
return LutFile { title, size: size3, data: rows }.check();
|
||||
}
|
||||
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");
|
||||
}
|
||||
if size1 >= 2 && rows.len() == size1 * 3 {
|
||||
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);
|
||||
@@ -195,13 +184,12 @@ 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 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 };
|
||||
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 };
|
||||
LutFile { title: tag("title").unwrap_or_default(), size, data }.check()
|
||||
}
|
||||
|
||||
@@ -304,19 +292,6 @@ 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]
|
||||
@@ -342,12 +317,6 @@ 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,11 +147,6 @@ 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 {
|
||||
@@ -221,43 +216,6 @@ 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,52 +590,3 @@ 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,10 +13,6 @@ 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
|
||||
@@ -30,11 +26,6 @@ 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
|
||||
|
||||
+9
-54
@@ -15,8 +15,7 @@ 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. On the way in, `Image::warnings` lists what the decoded image doesn't show: frames or
|
||||
pages left out, or data that ended early.
|
||||
changes.
|
||||
|
||||
```rust
|
||||
use photocraft_codecs::*;
|
||||
@@ -45,7 +44,6 @@ 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:
|
||||
@@ -66,17 +64,7 @@ the same.
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
* **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
|
||||
* **AVIF (the only asymmetric format).** 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`.
|
||||
@@ -85,55 +73,22 @@ 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. 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). Any TIFF page can still be opened
|
||||
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
|
||||
containers that can hold more frames.
|
||||
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
|
||||
`caps.exif = false` for TIFF, and a warning is raised.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
|
||||
BigTIFF) 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).
|
||||
* **EXIF orientation** is preserved as data but not applied to the pixels.
|
||||
* **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.
|
||||
* EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error.
|
||||
Extended XMP is not written.
|
||||
* **TIFF decode**
|
||||
* Classic TIFF and BigTIFF (version 43, 8-byte offsets), both byte orders, strips or tiles,
|
||||
planar configuration 1 or 2.
|
||||
* Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16),
|
||||
F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted),
|
||||
palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped
|
||||
unless the first is alpha).
|
||||
* Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330)
|
||||
with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a
|
||||
seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them.
|
||||
`decode` opens the first full-resolution page, like Photoshop (thumbnails are skipped).
|
||||
* Banded decoding: the image is allocated once at its final size and each strip or row of
|
||||
tiles is decoded straight into it, in parallel on native targets, with at most one
|
||||
strip/tile-sized scratch buffer per worker (30 MP RGB8: Deflate 345 → 32 ms, LZW 428 → 43
|
||||
ms in release). Image data cut off by the end of the file decodes with
|
||||
`DecodeWarning::Truncated`; a file far too small for its declared size (more than any
|
||||
compression could expand) is refused before allocating.
|
||||
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
|
||||
JPEG-in-TIFF) return `Unsupported`.
|
||||
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
|
||||
the file could pass 4 GiB.
|
||||
* Supports 1, 2 and 4-bit gray (best effort, not yet covered by tests), 8/16/32-bit integers (32-bit is reduced to U16), F16, F32 and
|
||||
F64 (converted to F32), and WhiteIsZero.
|
||||
* Palette and YCbCr TIFFs, and compressions that the `tiff` crate lacks (CCITT, JPEG-in-TIFF),
|
||||
return `Unsupported`.
|
||||
* **EXR**
|
||||
* Reads the first valid layer at full resolution, from its data window.
|
||||
* Channel names are matched by suffix, so `layer.R` counts as `R`.
|
||||
@@ -147,7 +102,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,9 +14,7 @@ cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
photocraft-codecs = { path = "..", features = ["heif"] }
|
||||
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
|
||||
heic-rs = "=0.1.1"
|
||||
photocraft-codecs = { path = ".." }
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
@@ -55,10 +53,3 @@ 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,7 +8,6 @@ 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,7 +6,6 @@ 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);
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
#![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,7 +6,6 @@ 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,7 +6,6 @@ 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);
|
||||
});
|
||||
@@ -1,20 +1,11 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, decode_tiff_page, exif_orientation, tiff_info, tiff_orientation, DecodeOptions, Format, Limits};
|
||||
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);
|
||||
let _ = exif_orientation(data);
|
||||
let _ = tiff_orientation(data);
|
||||
// Every directory of the chain and its SubIFDs (BigTIFF too), not only the default page.
|
||||
if let Ok(info) = tiff_info(data) {
|
||||
for page in 0..info.pages.len().min(16) {
|
||||
let _ = decode_tiff_page(data, page, &opts);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,568 +0,0 @@
|
||||
//! 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,15 +21,6 @@ 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)
|
||||
@@ -45,21 +36,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
limits.check_bytes(w, h, bpp)?;
|
||||
}
|
||||
|
||||
// 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 image =
|
||||
read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?;
|
||||
let layer = &image.layer_data;
|
||||
let (w, h) = (layer.size.0, layer.size.1);
|
||||
let channels = &layer.channel_data.list;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
//! 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,9 +10,8 @@ use zune_core::options::DecoderOptions;
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, 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";
|
||||
@@ -101,70 +100,11 @@ 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)))?;
|
||||
@@ -208,10 +148,7 @@ 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, ..Default::default() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -248,12 +185,14 @@ 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 });
|
||||
}
|
||||
// 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) {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
let mut seg = EXIF_HEADER.to_vec();
|
||||
seg.extend_from_slice(exif);
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(seg) = img.meta.xmp.as_deref().and_then(xmp_segment) {
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
let mut seg = XMP_HEADER.to_vec();
|
||||
seg.extend_from_slice(xmp.as_bytes());
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
}
|
||||
@@ -265,63 +204,3 @@ 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,11 +1,7 @@
|
||||
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;
|
||||
pub(crate) mod tiff;
|
||||
pub(crate) mod tiff_ifd;
|
||||
pub(crate) mod via_image;
|
||||
pub(crate) mod vp8;
|
||||
pub(crate) mod webp;
|
||||
@@ -6,7 +6,7 @@ use std::io::{Cursor, Write};
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
@@ -27,8 +27,6 @@ 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,
|
||||
@@ -84,9 +82,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -136,8 +131,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
// 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());
|
||||
info.exif_metadata = Some(exif.clone().into());
|
||||
}
|
||||
if let Some((x, y)) = img.meta.dpi
|
||||
&& x > 0.0
|
||||
@@ -170,7 +164,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(), crate::orientation::upright_xmp(xmp).into_owned()).map_err(|e| CodecError::encode(F, e))?;
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
}
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
@@ -264,7 +258,22 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress
|
||||
filter_row(row, prev, bpp, &mut scratch, &mut filtered);
|
||||
}
|
||||
let adler = adler32(1, &filtered);
|
||||
let Some(z) = deflate_band(&filtered, lvl, y1 == h) else { return Vec::new() };
|
||||
let mut c = flate2::Compress::new(lvl, false);
|
||||
let mut z = Vec::with_capacity(filtered.len() / 2 + 64);
|
||||
let last = y1 == h;
|
||||
let flush = if last { flate2::FlushCompress::Finish } else { flate2::FlushCompress::Sync };
|
||||
loop {
|
||||
let consumed = c.total_in() as usize;
|
||||
if z.capacity() - z.len() < 64 * 1024 {
|
||||
z.reserve(z.capacity().max(64 * 1024));
|
||||
}
|
||||
match c.compress_vec(&filtered[consumed..], &mut z, flush) {
|
||||
Ok(flate2::Status::StreamEnd) => break,
|
||||
Ok(_) if !last && c.total_in() as usize == filtered.len() && z.capacity() > z.len() => break,
|
||||
Ok(_) => {}
|
||||
Err(_) => return Vec::new(),
|
||||
}
|
||||
}
|
||||
out.push((i, z, adler, filtered.len()));
|
||||
}
|
||||
out
|
||||
@@ -293,22 +302,6 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band.
|
||||
/// The output buffer is sized past deflate's worst case so that one call takes the whole band
|
||||
/// and completes the flush: when a sync flush fills the buffer, miniz_oxide (flate2's Rust
|
||||
/// backend) can return before compressing the last lookahead bytes, and the next call only
|
||||
/// drains the pending output, so a resumed flush silently drops data. `None` (the caller then
|
||||
/// uses the serial encoder) if the single call did not finish.
|
||||
fn deflate_band(filtered: &[u8], lvl: flate2::Compression, last: bool) -> Option<Vec<u8>> {
|
||||
let mut c = flate2::Compress::new(lvl, false);
|
||||
// Stored blocks cost 5 bytes per 64 KiB, the flush an empty stored block: 1/8 is ample.
|
||||
let mut z = Vec::with_capacity(filtered.len() + filtered.len() / 8 + 1024);
|
||||
let flush = if last { flate2::FlushCompress::Finish } else { flate2::FlushCompress::Sync };
|
||||
let status = c.compress_vec(filtered, &mut z, flush).ok()?;
|
||||
let done = if last { status == flate2::Status::StreamEnd } else { c.total_in() as usize == filtered.len() && z.len() < z.capacity() };
|
||||
done.then_some(z)
|
||||
}
|
||||
|
||||
/// One scanline with the adaptive filter choice, appended to `out` (type byte + data).
|
||||
fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec<u8>) {
|
||||
let n = row.len();
|
||||
@@ -445,31 +438,6 @@ mod parallel_tests {
|
||||
assert_eq!(adler32_combine(adler32(1, &a), adler32(1, &b), b.len()), adler32(1, &whole));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_that_end_just_past_a_block_keep_all_their_data() {
|
||||
// Deflate output ~0.6 of the input, cut just past one of miniz_oxide's block
|
||||
// boundaries: a sync flush that fills the output buffer there used to lose the
|
||||
// last lookahead bytes, corrupting every band after it.
|
||||
for (k, len) in [(20u32, 136_916usize), (20, 345_044), (24, 195_796), (28, 189_142)] {
|
||||
let mut x = 0x9E37_79B9u32 ^ k;
|
||||
let data: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
((x >> 8) % k) as u8
|
||||
})
|
||||
.collect();
|
||||
for last in [false, true] {
|
||||
let z = deflate_band(&data, flate2::Compression::default(), last).unwrap();
|
||||
let mut d = flate2::Decompress::new(false);
|
||||
let mut back = Vec::with_capacity(len + 1);
|
||||
d.decompress_vec(&z, &mut back, flate2::FlushDecompress::Sync).unwrap();
|
||||
assert!(back == data, "k {k} len {len} last {last}: {} of {len} bytes", back.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_roundtrip_through_the_parallel_encoder() {
|
||||
for (layout, sample, ch, bytes) in [(ChannelLayout::Rgb, SampleType::U8, 3usize, 1usize), (ChannelLayout::Rgba, SampleType::U16, 4, 2)] {
|
||||
|
||||
+160
-872
File diff suppressed because it is too large.
Load diff
@@ -1,400 +0,0 @@
|
||||
//! A bounds-checked reader for the TIFF directory structure: classic TIFF (version 42, 4-byte
|
||||
//! offsets) and BigTIFF (version 43, 8-byte offsets and the LONG8 / SLONG8 / IFD8 types), in
|
||||
//! either byte order.
|
||||
//!
|
||||
//! Every offset and count comes from the file and is untrusted: reads go through `get` with
|
||||
//! checked arithmetic, directory tables must lie wholly inside the file, the IFD chain and the
|
||||
//! SubIFD trees (tag 330) are walked with a seen-set, a depth limit and a cap on the number of
|
||||
//! directories, and nothing is allocated in proportion to a declared count before the bytes
|
||||
//! behind it are known to exist.
|
||||
//!
|
||||
//! This module lists the pages of a file ([`TiffInfo`]) and reads the metadata of a directory
|
||||
//! without decoding pixels; `tiff.rs` decodes the pixels of one directory.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
|
||||
/// At most this many directories are listed (main chain and SubIFDs together).
|
||||
pub(crate) const MAX_IFDS: usize = 10_000;
|
||||
/// Entries per directory: the classic format's own limit (a `u16` count), applied to BigTIFF too.
|
||||
const MAX_ENTRIES: u64 = u16::MAX as u64;
|
||||
/// SubIFD nesting depth that is followed (real files use one level).
|
||||
const MAX_SUB_DEPTH: usize = 4;
|
||||
/// SubIFD offsets followed per directory.
|
||||
const MAX_SUBIFDS: u64 = 256;
|
||||
|
||||
pub(crate) const TAG_NEW_SUBFILE_TYPE: u16 = 254;
|
||||
pub(crate) const TAG_SUBFILE_TYPE: u16 = 255;
|
||||
pub(crate) const TAG_WIDTH: u16 = 256;
|
||||
pub(crate) const TAG_HEIGHT: u16 = 257;
|
||||
pub(crate) const TAG_BITS: u16 = 258;
|
||||
pub(crate) const TAG_COMPRESSION: u16 = 259;
|
||||
pub(crate) const TAG_PHOTOMETRIC: u16 = 262;
|
||||
pub(crate) const TAG_ORIENTATION: u16 = 274;
|
||||
pub(crate) const TAG_SAMPLES: u16 = 277;
|
||||
pub(crate) const TAG_ROWS_PER_STRIP: u16 = 278;
|
||||
pub(crate) const TAG_PLANAR: u16 = 284;
|
||||
pub(crate) const TAG_COLOR_MAP: u16 = 320;
|
||||
pub(crate) const TAG_TILE_WIDTH: u16 = 322;
|
||||
pub(crate) const TAG_TILE_LENGTH: u16 = 323;
|
||||
pub(crate) const TAG_TILE_OFFSETS: u16 = 324;
|
||||
pub(crate) const TAG_SUB_IFDS: u16 = 330;
|
||||
pub(crate) const TAG_EXTRA_SAMPLES: u16 = 338;
|
||||
pub(crate) const TAG_SAMPLE_FORMAT: u16 = 339;
|
||||
|
||||
/// Tags the `tiff` crate reads to set up decoding. Their values must lie inside the file, so a
|
||||
/// hostile count can neither make it allocate nor read past the end.
|
||||
pub(crate) const STRUCTURAL_TAGS: &[u16] = &[256, 257, 258, 259, 262, 273, 277, 278, 279, 284, 317, 322, 323, 324, 325, 338, 339, 347, 530];
|
||||
|
||||
/// Bytes per value of a TIFF field type; `None` for unknown types (whose entries are skipped).
|
||||
fn type_size(ty: u16) -> Option<u64> {
|
||||
Some(match ty {
|
||||
1 | 2 | 6 | 7 => 1, // BYTE, ASCII, SBYTE, UNDEFINED
|
||||
3 | 8 => 2, // SHORT, SSHORT
|
||||
4 | 9 | 11 | 13 => 4, // LONG, SLONG, FLOAT, IFD
|
||||
5 | 10 | 12 | 16 | 17 | 18 => 8, // RATIONAL, SRATIONAL, DOUBLE, LONG8, SLONG8, IFD8
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One directory entry, located but not read.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Entry {
|
||||
pub tag: u16,
|
||||
pub ty: u16,
|
||||
pub count: u64,
|
||||
/// Position of the value: inside the entry when it fits, else the stored offset.
|
||||
pub value_at: u64,
|
||||
}
|
||||
|
||||
/// One directory: its entries in file order and the next-IFD offset (0 at the end of a chain).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Ifd {
|
||||
pub at: u64,
|
||||
pub entries: Vec<Entry>,
|
||||
pub next: u64,
|
||||
/// Position of the next-IFD pointer field.
|
||||
pub next_at: u64,
|
||||
}
|
||||
|
||||
impl Ifd {
|
||||
/// The first entry with this tag.
|
||||
pub fn get(&self, tag: u16) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.tag == tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF or BigTIFF file's header and bytes.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct File<'a> {
|
||||
pub b: &'a [u8],
|
||||
pub le: bool,
|
||||
pub big: bool,
|
||||
pub first_ifd: u64,
|
||||
}
|
||||
|
||||
impl<'a> File<'a> {
|
||||
/// The header, or `None` when this is not a (Big)TIFF.
|
||||
pub fn parse(b: &'a [u8]) -> Option<Self> {
|
||||
let le = match b.get(0..2)? {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let mut f = File { b, le, big: false, first_ifd: 0 };
|
||||
match f.u16_at(2)? {
|
||||
42 => f.first_ifd = u64::from(f.u32_at(4)?),
|
||||
43 => {
|
||||
// Bytesize of offsets (always 8) and a reserved zero.
|
||||
if f.u16_at(4)? != 8 || f.u16_at(6)? != 0 {
|
||||
return None;
|
||||
}
|
||||
f.big = true;
|
||||
f.first_ifd = f.u64_at(8)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Length of the header; no directory can start inside it.
|
||||
pub fn header_len(&self) -> u64 {
|
||||
if self.big { 16 } else { 8 }
|
||||
}
|
||||
|
||||
fn slice(&self, at: u64, len: u64) -> Option<&'a [u8]> {
|
||||
let at = usize::try_from(at).ok()?;
|
||||
let len = usize::try_from(len).ok()?;
|
||||
self.b.get(at..at.checked_add(len)?)
|
||||
}
|
||||
|
||||
pub fn u16_at(&self, at: u64) -> Option<u16> {
|
||||
let s: [u8; 2] = self.slice(at, 2)?.try_into().ok()?;
|
||||
Some(if self.le { u16::from_le_bytes(s) } else { u16::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
pub fn u32_at(&self, at: u64) -> Option<u32> {
|
||||
let s: [u8; 4] = self.slice(at, 4)?.try_into().ok()?;
|
||||
Some(if self.le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
pub fn u64_at(&self, at: u64) -> Option<u64> {
|
||||
let s: [u8; 8] = self.slice(at, 8)?.try_into().ok()?;
|
||||
Some(if self.le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
/// The directory at `at`: its whole table, up to and including the next-IFD pointer, must
|
||||
/// be inside the file and after the header.
|
||||
pub fn ifd(&self, at: u64) -> Option<Ifd> {
|
||||
if at < self.header_len() {
|
||||
return None;
|
||||
}
|
||||
let (n, first, entry, inline) =
|
||||
if self.big { (self.u64_at(at)?, at.checked_add(8)?, 20u64, 8u64) } else { (u64::from(self.u16_at(at)?), at.checked_add(2)?, 12, 4) };
|
||||
if n > MAX_ENTRIES {
|
||||
return None;
|
||||
}
|
||||
let next_at = first.checked_add(n.checked_mul(entry)?)?;
|
||||
let next = if self.big { self.u64_at(next_at)? } else { u64::from(self.u32_at(next_at)?) };
|
||||
let mut entries = Vec::with_capacity(usize::try_from(n).ok()?);
|
||||
for i in 0..n {
|
||||
let e = first.checked_add(i.checked_mul(entry)?)?;
|
||||
let tag = self.u16_at(e)?;
|
||||
let ty = self.u16_at(e.checked_add(2)?)?;
|
||||
let count = if self.big { self.u64_at(e.checked_add(4)?)? } else { u64::from(self.u32_at(e.checked_add(4)?)?) };
|
||||
let field = e.checked_add(if self.big { 12 } else { 8 })?;
|
||||
// Unknown types are skipped, as the specification says.
|
||||
let Some(size) = type_size(ty) else { continue };
|
||||
let fits = count.checked_mul(size).is_some_and(|len| len <= inline);
|
||||
let value_at = if fits {
|
||||
field
|
||||
} else if self.big {
|
||||
self.u64_at(field)?
|
||||
} else {
|
||||
u64::from(self.u32_at(field)?)
|
||||
};
|
||||
entries.push(Entry { tag, ty, count, value_at });
|
||||
}
|
||||
Some(Ifd { at, entries, next, next_at })
|
||||
}
|
||||
|
||||
/// The value bytes of an entry, when they are all inside the file.
|
||||
pub fn data(&self, e: &Entry) -> Option<&'a [u8]> {
|
||||
self.slice(e.value_at, e.count.checked_mul(type_size(e.ty)?)?)
|
||||
}
|
||||
|
||||
/// Value `i` of an unsigned integer entry (BYTE, SHORT, LONG, LONG8, IFD, IFD8).
|
||||
pub fn uint(&self, e: &Entry, i: u64) -> Option<u64> {
|
||||
if i >= e.count {
|
||||
return None;
|
||||
}
|
||||
let size = type_size(e.ty)?;
|
||||
let at = e.value_at.checked_add(i.checked_mul(size)?)?;
|
||||
match e.ty {
|
||||
1 => self.slice(at, 1)?.first().map(|&v| u64::from(v)),
|
||||
3 => self.u16_at(at).map(u64::from),
|
||||
4 | 13 => self.u32_at(at).map(u64::from),
|
||||
16 | 18 => self.u64_at(at),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first value of an unsigned integer tag.
|
||||
pub fn tag_uint(&self, ifd: &Ifd, tag: u16) -> Option<u64> {
|
||||
self.uint(ifd.get(tag)?, 0)
|
||||
}
|
||||
|
||||
/// Up to `max` values of an unsigned integer tag (`None` if any of them is unreadable).
|
||||
pub fn tag_uints(&self, ifd: &Ifd, tag: u16, max: u64) -> Option<Vec<u64>> {
|
||||
let e = ifd.get(tag)?;
|
||||
(0..e.count.min(max)).map(|i| self.uint(e, i)).collect()
|
||||
}
|
||||
|
||||
/// The first value of a numeric tag as a float: RATIONAL, FLOAT, DOUBLE or an integer.
|
||||
pub fn tag_f64(&self, ifd: &Ifd, tag: u16) -> Option<f64> {
|
||||
let e = ifd.get(tag)?;
|
||||
if e.count == 0 {
|
||||
return None;
|
||||
}
|
||||
match e.ty {
|
||||
5 => {
|
||||
let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?);
|
||||
(d != 0).then(|| f64::from(n) / f64::from(d))
|
||||
}
|
||||
11 => Some(f64::from(f32::from_bits(self.u32_at(e.value_at)?))),
|
||||
12 => Some(f64::from_bits(self.u64_at(e.value_at)?)),
|
||||
_ => self.uint(e, 0).map(|v| v as f64),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of a one-byte-per-value tag (BYTE, ASCII, SBYTE or UNDEFINED).
|
||||
pub fn tag_bytes(&self, ifd: &Ifd, tag: u16) -> Option<&'a [u8]> {
|
||||
let e = ifd.get(tag)?;
|
||||
matches!(e.ty, 1 | 2 | 6 | 7).then(|| self.data(e)).flatten()
|
||||
}
|
||||
|
||||
/// SubIFD offsets of a directory (tag 330 as LONG, IFD, LONG8 or IFD8).
|
||||
fn sub_ifds(&self, ifd: &Ifd) -> Vec<u64> {
|
||||
let Some(e) = ifd.get(TAG_SUB_IFDS) else { return Vec::new() };
|
||||
if !matches!(e.ty, 4 | 13 | 16 | 18) {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..e.count.min(MAX_SUBIFDS)).map_while(|i| self.uint(e, i)).filter(|&o| o != 0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory's Orientation (1–8), with the rules of [`crate::exif_orientation`]: the first
|
||||
/// Orientation entry wins, it must be exactly one SHORT or LONG, and anything else reads as 1.
|
||||
pub(crate) fn orientation_of(f: &File<'_>, ifd: &Ifd) -> u16 {
|
||||
ifd.get(TAG_ORIENTATION)
|
||||
.filter(|e| e.count == 1 && matches!(e.ty, 3 | 4))
|
||||
.and_then(|e| f.uint(e, 0))
|
||||
.and_then(|v| u16::try_from(v).ok())
|
||||
.filter(|v| (1..=8).contains(v))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// What a TIFF directory holds, from `NewSubfileType` (254) or the older `SubfileType` (255).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TiffPageKind {
|
||||
/// A full-resolution image: a page of the document.
|
||||
Page,
|
||||
/// A reduced-resolution copy of another image (a thumbnail or a pyramid level).
|
||||
ReducedResolution,
|
||||
/// A transparency mask for another image.
|
||||
Mask,
|
||||
}
|
||||
|
||||
/// One image directory of a TIFF file, described without decoding its pixels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TiffPage {
|
||||
/// Byte offset of the directory in the file.
|
||||
pub ifd_offset: u64,
|
||||
/// For a SubIFD (tag 330): the index (in [`TiffInfo::pages`]) of the directory listing it.
|
||||
/// `None` for the directories of the main IFD chain.
|
||||
pub parent: Option<usize>,
|
||||
pub kind: TiffPageKind,
|
||||
/// 0 when the directory has no readable dimensions.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub samples_per_pixel: u16,
|
||||
pub bits_per_sample: u16,
|
||||
/// TIFF `SampleFormat`: 1 unsigned integer, 2 signed integer, 3 IEEE float.
|
||||
pub sample_format: u16,
|
||||
/// TIFF `PhotometricInterpretation`, when present.
|
||||
pub photometric: Option<u16>,
|
||||
/// TIFF `Compression` (1 = none).
|
||||
pub compression: u16,
|
||||
/// Tiles rather than strips.
|
||||
pub tiled: bool,
|
||||
/// `PlanarConfiguration` 2: each sample in its own plane.
|
||||
pub planar: bool,
|
||||
}
|
||||
|
||||
/// The directory structure of a TIFF file: its byte order, BigTIFF or not, and every image
|
||||
/// directory of the main chain and its SubIFDs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TiffInfo {
|
||||
pub big_tiff: bool,
|
||||
pub little_endian: bool,
|
||||
/// Main-chain directories in file order, each followed by its SubIFDs (depth first).
|
||||
pub pages: Vec<TiffPage>,
|
||||
/// `false` when the walk stopped early: an unreadable directory, a cycle, or more than
|
||||
/// 10 000 directories. The pages listed up to that point are still valid.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl TiffInfo {
|
||||
/// The page opened by default, like Photoshop: the first full-resolution directory of the
|
||||
/// main chain (reduced-resolution copies and masks are skipped), else the first directory.
|
||||
pub fn default_page(&self) -> Option<usize> {
|
||||
self.pages
|
||||
.iter()
|
||||
.position(|p| p.parent.is_none() && p.kind == TiffPageKind::Page && p.width > 0 && p.height > 0)
|
||||
.or_else(|| (!self.pages.is_empty()).then_some(0))
|
||||
}
|
||||
|
||||
/// Full-resolution pages in the main chain (what Photoshop calls the pages of the file).
|
||||
pub fn page_count(&self) -> usize {
|
||||
self.pages.iter().filter(|p| p.parent.is_none() && p.kind == TiffPageKind::Page).count()
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(f: &File<'_>, ifd: &Ifd, parent: Option<usize>) -> TiffPage {
|
||||
let new_kind = f.tag_uint(ifd, TAG_NEW_SUBFILE_TYPE).unwrap_or(0);
|
||||
let old_kind = f.tag_uint(ifd, TAG_SUBFILE_TYPE).unwrap_or(1);
|
||||
let kind = if new_kind & 4 != 0 {
|
||||
TiffPageKind::Mask
|
||||
} else if new_kind & 1 != 0 || old_kind == 2 {
|
||||
TiffPageKind::ReducedResolution
|
||||
} else {
|
||||
TiffPageKind::Page
|
||||
};
|
||||
let u32_of = |tag| f.tag_uint(ifd, tag).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let u16_of = |tag, default| f.tag_uint(ifd, tag).and_then(|v| u16::try_from(v).ok()).unwrap_or(default);
|
||||
TiffPage {
|
||||
ifd_offset: ifd.at,
|
||||
parent,
|
||||
kind,
|
||||
width: u32_of(TAG_WIDTH),
|
||||
height: u32_of(TAG_HEIGHT),
|
||||
samples_per_pixel: u16_of(TAG_SAMPLES, 1),
|
||||
bits_per_sample: u16_of(TAG_BITS, 1),
|
||||
sample_format: u16_of(TAG_SAMPLE_FORMAT, 1),
|
||||
photometric: f.tag_uint(ifd, TAG_PHOTOMETRIC).and_then(|v| u16::try_from(v).ok()),
|
||||
compression: u16_of(TAG_COMPRESSION, 1),
|
||||
tiled: ifd.get(TAG_TILE_OFFSETS).is_some(),
|
||||
planar: f.tag_uint(ifd, TAG_PLANAR) == Some(2),
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a, 'f> {
|
||||
f: &'f File<'a>,
|
||||
seen: HashSet<u64>,
|
||||
pages: Vec<TiffPage>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl Walk<'_, '_> {
|
||||
/// Follows a chain of directories starting at `at`, listing each with `parent` and then
|
||||
/// its SubIFDs.
|
||||
fn chain(&mut self, mut at: u64, parent: Option<usize>, depth: usize) {
|
||||
while at != 0 {
|
||||
if self.pages.len() >= MAX_IFDS || !self.seen.insert(at) {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
let Some(ifd) = self.f.ifd(at) else {
|
||||
self.complete = false;
|
||||
return;
|
||||
};
|
||||
let index = self.pages.len();
|
||||
self.pages.push(describe(self.f, &ifd, parent));
|
||||
if depth < MAX_SUB_DEPTH {
|
||||
for sub in self.f.sub_ifds(&ifd) {
|
||||
self.chain(sub, Some(index), depth + 1);
|
||||
}
|
||||
}
|
||||
at = ifd.next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists the directories of a (Big)TIFF file. An error only when the bytes are not a TIFF or
|
||||
/// the first directory cannot be read; a damaged chain after it ends the list early
|
||||
/// (`complete == false`).
|
||||
pub fn tiff_info(bytes: &[u8]) -> Result<TiffInfo, CodecError> {
|
||||
let f = File::parse(bytes).ok_or_else(|| CodecError::malformed(Format::Tiff, "not a TIFF or BigTIFF header"))?;
|
||||
info_of(&f)
|
||||
}
|
||||
|
||||
pub(crate) fn info_of(f: &File<'_>) -> Result<TiffInfo, CodecError> {
|
||||
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true };
|
||||
w.chain(f.first_ifd, None, 0);
|
||||
if w.pages.is_empty() {
|
||||
return Err(CodecError::malformed(Format::Tiff, "the first image directory is missing or cut off"));
|
||||
}
|
||||
Ok(TiffInfo { big_tiff: f.big, little_endian: f.le, pages: w.pages, complete: w.complete })
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use image::{DynamicImage, ImageDecoder, ImageFormat};
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
|
||||
fn image_format(f: Format) -> Option<ImageFormat> {
|
||||
@@ -49,49 +49,9 @@ 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 {
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
//! The VP8 boolean entropy coder, writing side (RFC 6386 §7): every symbol is a bool with an
|
||||
//! 8-bit probability of being zero; literals and tree-coded values are built from bools.
|
||||
|
||||
use super::tables::Tree;
|
||||
|
||||
/// Writes bools at given probabilities into a byte buffer.
|
||||
pub struct BoolEncoder {
|
||||
out: Vec<u8>,
|
||||
range: u32,
|
||||
bottom: u32,
|
||||
bit_count: i32,
|
||||
}
|
||||
|
||||
impl Default for BoolEncoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BoolEncoder {
|
||||
pub fn new() -> Self {
|
||||
BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24 }
|
||||
}
|
||||
|
||||
/// Propagates a carry into the bytes already written (never past the start: the coded
|
||||
/// value is below one).
|
||||
fn add_one_to_output(&mut self) {
|
||||
for b in self.out.iter_mut().rev() {
|
||||
if *b == 255 {
|
||||
*b = 0;
|
||||
} else {
|
||||
*b += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bit` whose probability of being 0 is `prob / 256`.
|
||||
pub fn put(&mut self, prob: u8, bit: bool) {
|
||||
let split = 1 + (((self.range - 1) * u32::from(prob)) >> 8);
|
||||
if bit {
|
||||
self.bottom = self.bottom.wrapping_add(split);
|
||||
self.range -= split;
|
||||
} else {
|
||||
self.range = split;
|
||||
}
|
||||
while self.range < 128 {
|
||||
self.range <<= 1;
|
||||
if self.bottom & (1 << 31) != 0 {
|
||||
self.add_one_to_output();
|
||||
}
|
||||
self.bottom <<= 1;
|
||||
self.bit_count -= 1;
|
||||
if self.bit_count == 0 {
|
||||
self.out.push((self.bottom >> 24) as u8);
|
||||
self.bottom &= (1 << 24) - 1;
|
||||
self.bit_count = 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bool at probability 1/2.
|
||||
pub fn put_flag(&mut self, bit: bool) {
|
||||
self.put(128, bit);
|
||||
}
|
||||
|
||||
/// An `n`-bit unsigned literal, most significant bit first, each bit at probability 1/2.
|
||||
pub fn put_literal(&mut self, n: u32, value: u32) {
|
||||
for i in (0..n).rev() {
|
||||
self.put_flag((value >> i) & 1 != 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree-coded value: the path of bools from the root to the leaf `value`, each node's bool
|
||||
/// at `probs[node / 2]`.
|
||||
pub fn put_tree(&mut self, tree: Tree, probs: &[u8], value: u8) {
|
||||
self.put_tree_from(tree, probs, value, 0);
|
||||
}
|
||||
|
||||
/// [`Self::put_tree`] starting at node `start` (2 skips the first branch of the coefficient tree).
|
||||
pub fn put_tree_from(&mut self, tree: Tree, probs: &[u8], value: u8, start: usize) {
|
||||
// Find the path to the leaf by searching the tree; trees are tiny (≤ 22 entries).
|
||||
let mut path = Vec::with_capacity(8);
|
||||
if !Self::find(tree, start, value, &mut path) {
|
||||
return;
|
||||
}
|
||||
for (node, bit) in path {
|
||||
self.put(probs.get(node >> 1).copied().unwrap_or(128), bit);
|
||||
}
|
||||
}
|
||||
|
||||
fn find(tree: Tree, node: usize, value: u8, path: &mut Vec<(usize, bool)>) -> bool {
|
||||
for bit in [false, true] {
|
||||
let i = node + usize::from(bit);
|
||||
let Some(&entry) = tree.get(i) else { continue };
|
||||
path.push((node, bit));
|
||||
if entry <= 0 {
|
||||
if (-entry) as u8 == value {
|
||||
return true;
|
||||
}
|
||||
} else if Self::find(tree, entry as usize, value, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Finishes the partition and returns its bytes.
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
let mut c = self.bit_count;
|
||||
let mut v = self.bottom;
|
||||
if c <= 32 && v & (1u32 << (32 - c)) != 0 {
|
||||
self.add_one_to_output();
|
||||
}
|
||||
v <<= (c & 7) as u32;
|
||||
c >>= 3;
|
||||
while c > 0 {
|
||||
v <<= 8;
|
||||
c -= 1;
|
||||
}
|
||||
for _ in 0..4 {
|
||||
self.out.push((v >> 24) as u8);
|
||||
v <<= 8;
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The decoder from RFC 6386 §7.3, for the round-trip tests.
|
||||
struct BoolDecoder<'a> {
|
||||
input: &'a [u8],
|
||||
pos: usize,
|
||||
range: u32,
|
||||
value: u32,
|
||||
bit_count: i32,
|
||||
}
|
||||
|
||||
impl<'a> BoolDecoder<'a> {
|
||||
fn new(input: &'a [u8]) -> Self {
|
||||
let mut value = 0;
|
||||
for i in 0..2 {
|
||||
value = (value << 8) | u32::from(input.get(i).copied().unwrap_or(0));
|
||||
}
|
||||
BoolDecoder { input, pos: 2, range: 255, value, bit_count: 0 }
|
||||
}
|
||||
fn get(&mut self, prob: u8) -> bool {
|
||||
let split = 1 + (((self.range - 1) * u32::from(prob)) >> 8);
|
||||
let big_split = split << 8;
|
||||
let bit = if self.value >= big_split {
|
||||
self.range -= split;
|
||||
self.value -= big_split;
|
||||
true
|
||||
} else {
|
||||
self.range = split;
|
||||
false
|
||||
};
|
||||
while self.range < 128 {
|
||||
self.value <<= 1;
|
||||
self.range <<= 1;
|
||||
self.bit_count += 1;
|
||||
if self.bit_count == 8 {
|
||||
self.bit_count = 0;
|
||||
self.value |= u32::from(self.input.get(self.pos).copied().unwrap_or(0));
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
bit
|
||||
}
|
||||
fn literal(&mut self, n: u32) -> u32 {
|
||||
(0..n).fold(0, |v, _| (v << 1) | u32::from(self.get(128)))
|
||||
}
|
||||
fn tree(&mut self, tree: Tree, probs: &[u8], start: usize) -> u8 {
|
||||
let mut i = start;
|
||||
loop {
|
||||
let bit = self.get(probs[i >> 1]);
|
||||
let entry = tree[i + usize::from(bit)];
|
||||
if entry <= 0 {
|
||||
return (-entry) as u8;
|
||||
}
|
||||
i = entry as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bools_round_trip_at_every_probability() {
|
||||
let mut rng = 0x1234_5678_9abc_def0u64;
|
||||
let mut next = || {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 7;
|
||||
rng ^= rng << 17;
|
||||
rng
|
||||
};
|
||||
let symbols: Vec<(u8, bool)> = (0..20_000)
|
||||
.map(|_| {
|
||||
let r = next();
|
||||
let prob = (r & 0xff) as u8;
|
||||
// Bits follow their probability, roughly, so both paths are exercised.
|
||||
((prob.max(1)), (r >> 8) & 0xff >= u64::from(prob))
|
||||
})
|
||||
.collect();
|
||||
let mut e = BoolEncoder::new();
|
||||
for &(p, b) in &symbols {
|
||||
e.put(p, b);
|
||||
}
|
||||
let bytes = e.finish();
|
||||
let mut d = BoolDecoder::new(&bytes);
|
||||
for &(p, b) in &symbols {
|
||||
assert_eq!(d.get(p), b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literals_and_trees_round_trip() {
|
||||
use super::super::tables::*;
|
||||
let mut e = BoolEncoder::new();
|
||||
e.put_literal(7, 93);
|
||||
e.put_literal(19, 0x7_ffff);
|
||||
e.put_literal(1, 0);
|
||||
e.put_tree(KF_YMODE_TREE, &KF_YMODE_PROB, TM_PRED);
|
||||
e.put_tree(KF_YMODE_TREE, &KF_YMODE_PROB, B_PRED);
|
||||
e.put_tree(UV_MODE_TREE, &KF_UV_MODE_PROB, H_PRED);
|
||||
for m in 0..10u8 {
|
||||
e.put_tree(BMODE_TREE, &KF_BMODE_PROBS[3][7], m);
|
||||
}
|
||||
let probs = [200u8, 150, 100, 128, 128, 128, 128, 128, 128, 128, 128];
|
||||
for t in 0..12u8 {
|
||||
e.put_tree(COEFF_TREE, &probs, t);
|
||||
}
|
||||
// From node 2 (after a zero token) the end-of-block leaf is unreachable: tokens 0..=10.
|
||||
for t in 0..11u8 {
|
||||
e.put_tree_from(COEFF_TREE, &probs, t, 2);
|
||||
}
|
||||
let bytes = e.finish();
|
||||
let mut d = BoolDecoder::new(&bytes);
|
||||
assert_eq!(d.literal(7), 93);
|
||||
assert_eq!(d.literal(19), 0x7_ffff);
|
||||
assert_eq!(d.literal(1), 0);
|
||||
assert_eq!(d.tree(KF_YMODE_TREE, &KF_YMODE_PROB, 0), TM_PRED);
|
||||
assert_eq!(d.tree(KF_YMODE_TREE, &KF_YMODE_PROB, 0), B_PRED);
|
||||
assert_eq!(d.tree(UV_MODE_TREE, &KF_UV_MODE_PROB, 0), H_PRED);
|
||||
for m in 0..10u8 {
|
||||
assert_eq!(d.tree(BMODE_TREE, &KF_BMODE_PROBS[3][7], 0), m);
|
||||
}
|
||||
for t in 0..12u8 {
|
||||
assert_eq!(d.tree(COEFF_TREE, &probs, 0), t);
|
||||
}
|
||||
for t in 0..11u8 {
|
||||
assert_eq!(d.tree(COEFF_TREE, &probs, 2), t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carry_propagates_through_full_bytes() {
|
||||
// Many ones at a tiny probability push the interval up and force carries.
|
||||
let mut e = BoolEncoder::new();
|
||||
for _ in 0..3000 {
|
||||
e.put(1, true);
|
||||
e.put(255, false);
|
||||
}
|
||||
let bytes = e.finish();
|
||||
let mut d = BoolDecoder::new(&bytes);
|
||||
for _ in 0..3000 {
|
||||
assert!(d.get(1));
|
||||
assert!(!d.get(255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,839 +0,0 @@
|
||||
//! A clean-room VP8 key-frame encoder (the lossy WebP bitstream), implemented from RFC 6386.
|
||||
//!
|
||||
//! One intra frame: 16×16 macroblocks in raster order, each predicted from the frame's own
|
||||
//! reconstruction with one of the four whole-block luma modes or the sixteen 4×4 sub-block
|
||||
//! modes (`B_PRED`), chroma with one of four modes; the residual goes through the 4×4 DCT
|
||||
//! (and the WHT of the luma DCs for whole-block modes), one frame-wide quantizer derived from the
|
||||
//! quality, and the boolean entropy coder with the specification's default token probabilities,
|
||||
//! refined per frame from the frame's own token statistics. Macroblocks without coefficients
|
||||
//! are skipped. The loop filter is left to the decoder at a level matched to the quantizer.
|
||||
//!
|
||||
//! What it does not do (yet): rate-distortion optimized mode decisions and quantization,
|
||||
//! segments, multiple token partitions, or SIMD. It is deterministic, allocation-bounded and
|
||||
//! never panics: dimensions are checked up front and every index is derived from them.
|
||||
|
||||
mod bool_encoder;
|
||||
mod predict;
|
||||
mod tables;
|
||||
mod transform;
|
||||
|
||||
use bool_encoder::BoolEncoder;
|
||||
use predict::{Plane, SubEdges, predict_block, predict_sub};
|
||||
use tables::*;
|
||||
use transform::{fdct, fwht, idct, iwht};
|
||||
|
||||
/// The largest dimension a VP8 frame header can hold (14 bits).
|
||||
pub const MAX_DIMENSION: u32 = 16383;
|
||||
|
||||
/// A planar 4:2:0 picture: `y` is `width × height`, `u` and `v` are `ceil(w/2) × ceil(h/2)`.
|
||||
pub struct Yuv {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub y: Vec<u8>,
|
||||
pub u: Vec<u8>,
|
||||
pub v: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Yuv {
|
||||
/// Converts interleaved 8-bit RGB (`stride` bytes per pixel, 3 or 4) to studio-range BT.601
|
||||
/// Y'CbCr with the chroma averaged over each 2 × 2 block, the convention WebP decoders invert.
|
||||
pub fn from_rgb(rgb: &[u8], width: u32, height: u32, stride: usize) -> Yuv {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut y = vec![0u8; w * h];
|
||||
let mut u = vec![0u8; cw * ch];
|
||||
let mut v = vec![0u8; cw * ch];
|
||||
let px = |x: usize, yy: usize| -> (i32, i32, i32) {
|
||||
let i = (yy.min(h - 1) * w + x.min(w - 1)) * stride;
|
||||
(i32::from(rgb[i]), i32::from(rgb[i + 1]), i32::from(rgb[i + 2]))
|
||||
};
|
||||
for yy in 0..h {
|
||||
for x in 0..w {
|
||||
let (r, g, b) = px(x, yy);
|
||||
y[yy * w + x] = (((66 * r + 129 * g + 25 * b + 128) >> 8) + 16) as u8;
|
||||
}
|
||||
}
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let (mut r, mut g, mut b) = (0, 0, 0);
|
||||
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let (pr, pg, pb) = px(cx * 2 + dx, cy * 2 + dy);
|
||||
r += pr;
|
||||
g += pg;
|
||||
b += pb;
|
||||
}
|
||||
let (r, g, b) = ((r + 2) >> 2, (g + 2) >> 2, (b + 2) >> 2);
|
||||
u[cy * cw + cx] = (((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128).clamp(0, 255) as u8;
|
||||
v[cy * cw + cx] = (((112 * r - 94 * g - 18 * b + 128) >> 8) + 128).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
Yuv { width, height, y, u, v }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoder settings.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Params {
|
||||
/// 0 (smallest) … 100 (best); maps to the frame quantizer.
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
/// Why a frame cannot be encoded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Vp8Error {
|
||||
/// A dimension is 0 or above [`MAX_DIMENSION`].
|
||||
Dimensions,
|
||||
/// The plane buffers do not match the dimensions.
|
||||
Planes,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Vp8Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Vp8Error::Dimensions => write!(f, "VP8 frames are 1 to {MAX_DIMENSION} pixels per side"),
|
||||
Vp8Error::Planes => write!(f, "plane sizes do not match the dimensions"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality 0..=100 → quantizer index 0..=127 (0 is finest). A gentle curve: quality 90 ≈ 11,
|
||||
/// 75 ≈ 28, 50 ≈ 60, 25 ≈ 92.
|
||||
pub fn quantizer_index(quality: u8) -> u8 {
|
||||
let q = f64::from(quality.min(100));
|
||||
let t = (100.0 - q) / 100.0;
|
||||
(t.powf(1.1) * 127.0).round().clamp(0.0, 127.0) as u8
|
||||
}
|
||||
|
||||
/// Dequantization factors for one quantizer index (RFC 6386 §14.1 and dixie's `dequant_init`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Quant {
|
||||
y_dc: i32,
|
||||
y_ac: i32,
|
||||
y2_dc: i32,
|
||||
y2_ac: i32,
|
||||
uv_dc: i32,
|
||||
uv_ac: i32,
|
||||
}
|
||||
|
||||
impl Quant {
|
||||
fn new(qi: u8) -> Quant {
|
||||
let qi = usize::from(qi.min(127));
|
||||
Quant {
|
||||
y_dc: DC_QLOOKUP[qi],
|
||||
y_ac: AC_QLOOKUP[qi],
|
||||
y2_dc: DC_QLOOKUP[qi] * 2,
|
||||
y2_ac: (AC_QLOOKUP[qi] * 155 / 100).max(8),
|
||||
uv_dc: DC_QLOOKUP[qi].min(132),
|
||||
uv_ac: AC_QLOOKUP[qi],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantizes one coefficient: round to nearest for DC, with a small dead zone for AC (fewer
|
||||
/// ±1 coefficients cost far fewer bits than they are worth in distortion).
|
||||
#[inline]
|
||||
fn quantize(coeff: i32, factor: i32, dc: bool) -> i32 {
|
||||
let bias = if dc { factor / 2 } else { factor * 7 / 16 };
|
||||
let q = (coeff.abs() + bias) / factor;
|
||||
let q = q.min(2048);
|
||||
if coeff < 0 { -q } else { q }
|
||||
}
|
||||
|
||||
/// Block types of the token probability table (RFC 6386 §13.3).
|
||||
const TYPE_Y_AFTER_Y2: usize = 0;
|
||||
const TYPE_Y2: usize = 1;
|
||||
const TYPE_UV: usize = 2;
|
||||
const TYPE_Y_WITH_DC: usize = 3;
|
||||
|
||||
/// Which coefficient token codes `v` and, for the categories, the extra bits to send.
|
||||
fn token_of(v: i32) -> (u8, Option<(usize, u32)>) {
|
||||
let a = v.unsigned_abs() as i32;
|
||||
match a {
|
||||
0 => (DCT_0, None),
|
||||
1 => (DCT_1, None),
|
||||
2 => (DCT_2, None),
|
||||
3 => (DCT_3, None),
|
||||
4 => (DCT_4, None),
|
||||
5..=6 => (DCT_CAT1, Some((0, (a - CAT_BASE[0]) as u32))),
|
||||
7..=10 => (DCT_CAT2, Some((1, (a - CAT_BASE[1]) as u32))),
|
||||
11..=18 => (DCT_CAT3, Some((2, (a - CAT_BASE[2]) as u32))),
|
||||
19..=34 => (DCT_CAT4, Some((3, (a - CAT_BASE[3]) as u32))),
|
||||
35..=66 => (DCT_CAT5, Some((4, (a - CAT_BASE[4]) as u32))),
|
||||
_ => (DCT_CAT6, Some((5, (a - CAT_BASE[5]) as u32))),
|
||||
}
|
||||
}
|
||||
|
||||
/// One coded block's quantized coefficients in zig-zag order, from `first` (1 for Y after Y2).
|
||||
struct Block {
|
||||
coeffs: [i32; 16],
|
||||
first: usize,
|
||||
/// One past the last non-zero zig-zag position (`first` when the block is empty).
|
||||
eob: usize,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
fn from_raster(raster: &[i32; 16], first: usize) -> Block {
|
||||
let mut coeffs = [0i32; 16];
|
||||
for (pos, &idx) in ZIGZAG.iter().enumerate() {
|
||||
coeffs[pos] = raster[idx];
|
||||
}
|
||||
let eob = (first..16).rev().find(|&p| coeffs[p] != 0).map_or(first, |p| p + 1);
|
||||
Block { coeffs, first, eob }
|
||||
}
|
||||
|
||||
fn has_coeffs(&self) -> bool {
|
||||
self.eob > self.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Token-count statistics, `[type][band][ctx][token]`, from which the frame's probabilities are
|
||||
/// derived (and the per-node branch counts needed to turn them into tree probabilities).
|
||||
#[derive(Clone)]
|
||||
struct Stats {
|
||||
/// Per tree node (11 per context): how often the bool was 0 and how often 1.
|
||||
node: Vec<[[[[u32; 2]; 11]; 3]; 8]>,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
fn new() -> Stats {
|
||||
Stats { node: vec![[[[[0; 2]; 11]; 3]; 8]; 4] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything about a block's tokens the two passes share: the branch path of each token in the
|
||||
/// coefficient tree. Walked once per block to count branches, once to write them.
|
||||
fn tree_path(value: u8, start: usize) -> Vec<(usize, bool)> {
|
||||
fn find(node: usize, value: u8, path: &mut Vec<(usize, bool)>) -> bool {
|
||||
for bit in [false, true] {
|
||||
let i = node + usize::from(bit);
|
||||
let Some(&entry) = COEFF_TREE.get(i) else { continue };
|
||||
path.push((node >> 1, bit));
|
||||
if entry <= 0 {
|
||||
if (-entry) as u8 == value {
|
||||
return true;
|
||||
}
|
||||
} else if find(entry as usize, value, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut path = Vec::with_capacity(8);
|
||||
find(start, value, &mut path);
|
||||
path
|
||||
}
|
||||
|
||||
/// One bool of a block's token sequence.
|
||||
enum Event {
|
||||
/// A coefficient-tree branch: `(type, band, ctx, node, bit)`, coded with `probs[type][band][ctx][node]`.
|
||||
Node(usize, usize, usize, usize, bool),
|
||||
/// The extra bits of a category token: `(category, bits)`.
|
||||
Extra(usize, u32),
|
||||
/// The sign of a non-zero coefficient (`true` = negative).
|
||||
Sign(bool),
|
||||
}
|
||||
|
||||
/// Visits every bool of a block's token sequence in coding order.
|
||||
fn for_each_token_bool(block: &Block, ty: usize, ctx0: usize, mut f: impl FnMut(Event)) {
|
||||
let mut ctx = ctx0;
|
||||
let mut prev_zero = false;
|
||||
let mut pos = block.first;
|
||||
while pos < block.eob {
|
||||
let v = block.coeffs[pos];
|
||||
let (token, cat) = token_of(v);
|
||||
let band = COEFF_BANDS[pos];
|
||||
for (node, bit) in tree_path(token, if prev_zero { 2 } else { 0 }) {
|
||||
f(Event::Node(ty, band, ctx, node, bit));
|
||||
}
|
||||
if let Some((c, bits)) = cat {
|
||||
f(Event::Extra(c, bits));
|
||||
}
|
||||
if v != 0 {
|
||||
f(Event::Sign(v < 0));
|
||||
}
|
||||
ctx = match v.abs() {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
_ => 2,
|
||||
};
|
||||
prev_zero = v == 0;
|
||||
pos += 1;
|
||||
}
|
||||
if block.eob < 16 {
|
||||
// End of block (never directly after a zero: the coded run ends with a non-zero value).
|
||||
let band = COEFF_BANDS[block.eob];
|
||||
for (node, bit) in tree_path(DCT_EOB, 0) {
|
||||
f(Event::Node(ty, band, ctx, node, bit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A macroblock's decisions and coefficients.
|
||||
struct Macroblock {
|
||||
y_mode: u8,
|
||||
b_modes: [u8; 16],
|
||||
uv_mode: u8,
|
||||
/// Y2 (whole-block modes only), 16 Y, 4 U, 4 V.
|
||||
y2: Option<Block>,
|
||||
y: Vec<Block>,
|
||||
u: Vec<Block>,
|
||||
v: Vec<Block>,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
/// Non-zero contexts: 4 Y (by column for "above", by row for "left"), 2 U, 2 V, 1 Y2.
|
||||
type Ctx = [u8; 9];
|
||||
|
||||
struct Encoder<'a> {
|
||||
src: &'a Yuv,
|
||||
mb_cols: usize,
|
||||
mb_rows: usize,
|
||||
q: Quant,
|
||||
rec_y: Plane,
|
||||
rec_u: Plane,
|
||||
rec_v: Plane,
|
||||
mbs: Vec<Macroblock>,
|
||||
}
|
||||
|
||||
impl<'a> Encoder<'a> {
|
||||
fn new(src: &'a Yuv, qi: u8) -> Encoder<'a> {
|
||||
let mb_cols = (src.width as usize).div_ceil(16);
|
||||
let mb_rows = (src.height as usize).div_ceil(16);
|
||||
Encoder {
|
||||
src,
|
||||
mb_cols,
|
||||
mb_rows,
|
||||
q: Quant::new(qi),
|
||||
rec_y: Plane::new(mb_cols * 16, mb_rows * 16),
|
||||
rec_u: Plane::new(mb_cols * 8, mb_rows * 8),
|
||||
rec_v: Plane::new(mb_cols * 8, mb_rows * 8),
|
||||
mbs: Vec::with_capacity(mb_cols * mb_rows),
|
||||
}
|
||||
}
|
||||
|
||||
/// Source luma at (`x`, `y`), the frame's last row and column extended over the padding.
|
||||
#[inline]
|
||||
fn src_y(&self, x: usize, y: usize) -> i32 {
|
||||
let (w, h) = (self.src.width as usize, self.src.height as usize);
|
||||
i32::from(self.src.y[y.min(h - 1) * w + x.min(w - 1)])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn src_c(&self, plane: &[u8], x: usize, y: usize) -> i32 {
|
||||
let (w, h) = ((self.src.width as usize).div_ceil(2), (self.src.height as usize).div_ceil(2));
|
||||
i32::from(plane[y.min(h - 1) * w + x.min(w - 1)])
|
||||
}
|
||||
|
||||
/// Encodes one 4×4 block: residual → coefficients → quantized (in raster order, with the
|
||||
/// quantization factors) → the dequantized coefficients the decoder will see.
|
||||
fn code_block(residual: &[i32; 16], dc_factor: i32, ac_factor: i32, with_dc: bool) -> ([i32; 16], [i32; 16]) {
|
||||
let coeffs = fdct(residual);
|
||||
let mut quantized = [0i32; 16];
|
||||
let mut dequantized = [0i32; 16];
|
||||
for i in 0..16 {
|
||||
let factor = if i == 0 { dc_factor } else { ac_factor };
|
||||
if i == 0 && !with_dc {
|
||||
continue;
|
||||
}
|
||||
quantized[i] = quantize(coeffs[i], factor, i == 0);
|
||||
dequantized[i] = quantized[i] * factor;
|
||||
}
|
||||
(quantized, dequantized)
|
||||
}
|
||||
|
||||
/// Sum of squared differences between a predicted block and the source.
|
||||
fn sse(pred: &[u8], src: &[i32]) -> u64 {
|
||||
pred.iter()
|
||||
.zip(src)
|
||||
.map(|(&p, &s)| {
|
||||
let d = i64::from(p) - i64::from(s);
|
||||
(d * d) as u64
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
// Sub-block indices address several arrays by geometry; ranges read better than iterators here.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn encode_macroblock(&mut self, mbx: usize, mby: usize) {
|
||||
let (x0, y0) = (mbx * 16, mby * 16);
|
||||
// Source luma of this macroblock.
|
||||
let mut src = [0i32; 256];
|
||||
for r in 0..16 {
|
||||
for c in 0..16 {
|
||||
src[r * 16 + c] = self.src_y(x0 + c, y0 + r);
|
||||
}
|
||||
}
|
||||
// Whole-block luma modes: the least squared prediction error wins.
|
||||
let mut pred16 = [0u8; 256];
|
||||
let mut best_mode = DC_PRED;
|
||||
let mut best_sse = u64::MAX;
|
||||
let mut best_pred = [0u8; 256];
|
||||
for mode in [DC_PRED, V_PRED, H_PRED, TM_PRED] {
|
||||
predict_block(&self.rec_y, x0, y0, 16, mode, &mut pred16);
|
||||
let e = Self::sse(&pred16, &src);
|
||||
if e < best_sse {
|
||||
best_sse = e;
|
||||
best_mode = mode;
|
||||
best_pred = pred16;
|
||||
}
|
||||
}
|
||||
// Rate-aware comparison with B_PRED: each sub-block mode costs about three bits, and
|
||||
// the whole-block path codes the DCs once in Y2. lambda scales distortion per bit with
|
||||
// the quantizer (squared error units).
|
||||
let lambda = u64::from((self.q.y_ac * self.q.y_ac) as u32) / 8;
|
||||
let whole_cost = best_sse + 3 * lambda;
|
||||
|
||||
// B_PRED: sub-blocks in raster order, each predicted from the reconstruction so far.
|
||||
let saved_y = self.rec_y.data.clone();
|
||||
let mut b_modes = [B_DC_PRED; 16];
|
||||
let mut b_blocks: Vec<Block> = Vec::with_capacity(16);
|
||||
let mut b_sse = 0u64;
|
||||
{
|
||||
for b in 0..16 {
|
||||
let (bx, by) = (x0 + (b & 3) * 4, y0 + (b >> 2) * 4);
|
||||
let edges = SubEdges::gather(&self.rec_y, x0, y0, b, self.mb_cols);
|
||||
let mut sub_src = [0i32; 16];
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
sub_src[r * 4 + c] = src[((b >> 2) * 4 + r) * 16 + (b & 3) * 4 + c];
|
||||
}
|
||||
}
|
||||
let mut best = (u64::MAX, B_DC_PRED, [0u8; 16]);
|
||||
let mut pred = [0u8; 16];
|
||||
for mode in 0..10u8 {
|
||||
predict_sub(&edges, mode, &mut pred);
|
||||
let e = Self::sse(&pred, &sub_src);
|
||||
if e < best.0 {
|
||||
best = (e, mode, pred);
|
||||
}
|
||||
}
|
||||
b_modes[b] = best.1;
|
||||
b_sse += best.0;
|
||||
let residual: [i32; 16] = std::array::from_fn(|i| sub_src[i] - i32::from(best.2[i]));
|
||||
let (quantized, dequantized) = Self::code_block(&residual, self.q.y_dc, self.q.y_ac, true);
|
||||
let recon = idct(&dequantized);
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
self.rec_y.set(bx + c, by + r, (i32::from(best.2[r * 4 + c]) + recon[r * 4 + c]).clamp(0, 255) as u8);
|
||||
}
|
||||
}
|
||||
b_blocks.push(Block::from_raster(&quantized, 0));
|
||||
}
|
||||
}
|
||||
let b_cost = b_sse + 50 * lambda;
|
||||
|
||||
let (y_mode, y2, y_blocks) = if b_cost < whole_cost {
|
||||
(B_PRED, None, b_blocks)
|
||||
} else {
|
||||
// Whole-block mode: undo the B_PRED reconstruction, code 16 blocks + Y2.
|
||||
self.rec_y.data = saved_y;
|
||||
let mut dcs = [0i32; 16];
|
||||
let mut ac_blocks: Vec<[i32; 16]> = Vec::with_capacity(16);
|
||||
let mut deq_blocks: Vec<[i32; 16]> = Vec::with_capacity(16);
|
||||
for b in 0..16 {
|
||||
let mut residual = [0i32; 16];
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
let i = ((b >> 2) * 4 + r) * 16 + (b & 3) * 4 + c;
|
||||
residual[r * 4 + c] = src[i] - i32::from(best_pred[i]);
|
||||
}
|
||||
}
|
||||
let coeffs = fdct(&residual);
|
||||
dcs[b] = coeffs[0];
|
||||
let mut quantized = [0i32; 16];
|
||||
let mut dequantized = [0i32; 16];
|
||||
for i in 1..16 {
|
||||
quantized[i] = quantize(coeffs[i], self.q.y_ac, false);
|
||||
dequantized[i] = quantized[i] * self.q.y_ac;
|
||||
}
|
||||
ac_blocks.push(quantized);
|
||||
deq_blocks.push(dequantized);
|
||||
}
|
||||
// The DCs through the WHT, quantized as Y2, and back through the inverse WHT so the
|
||||
// reconstruction uses exactly the decoder's DC values.
|
||||
let y2_coeffs = fwht(&dcs);
|
||||
let mut y2_q = [0i32; 16];
|
||||
let mut y2_deq = [0i32; 16];
|
||||
for i in 0..16 {
|
||||
let factor = if i == 0 { self.q.y2_dc } else { self.q.y2_ac };
|
||||
y2_q[i] = quantize(y2_coeffs[i], factor, i == 0);
|
||||
y2_deq[i] = y2_q[i] * factor;
|
||||
}
|
||||
let dc_rec = iwht(&y2_deq);
|
||||
for b in 0..16 {
|
||||
deq_blocks[b][0] = dc_rec[b];
|
||||
let recon = idct(&deq_blocks[b]);
|
||||
let (bx, by) = (x0 + (b & 3) * 4, y0 + (b >> 2) * 4);
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
let i = ((b >> 2) * 4 + r) * 16 + (b & 3) * 4 + c;
|
||||
self.rec_y.set(bx + c, by + r, (i32::from(best_pred[i]) + recon[r * 4 + c]).clamp(0, 255) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let blocks = ac_blocks.iter().map(|q| Block::from_raster(q, 1)).collect();
|
||||
(best_mode, Some(Block::from_raster(&y2_q, 0)), blocks)
|
||||
};
|
||||
|
||||
// Chroma: one mode for both planes.
|
||||
let (cx0, cy0) = (mbx * 8, mby * 8);
|
||||
let mut src_u = [0i32; 64];
|
||||
let mut src_v = [0i32; 64];
|
||||
for r in 0..8 {
|
||||
for c in 0..8 {
|
||||
src_u[r * 8 + c] = self.src_c(&self.src.u, cx0 + c, cy0 + r);
|
||||
src_v[r * 8 + c] = self.src_c(&self.src.v, cx0 + c, cy0 + r);
|
||||
}
|
||||
}
|
||||
let mut uv_mode = DC_PRED;
|
||||
let mut uv_best = u64::MAX;
|
||||
let mut pred_u = [0u8; 64];
|
||||
let mut pred_v = [0u8; 64];
|
||||
let mut pu = [0u8; 64];
|
||||
let mut pv = [0u8; 64];
|
||||
for mode in [DC_PRED, V_PRED, H_PRED, TM_PRED] {
|
||||
predict_block(&self.rec_u, cx0, cy0, 8, mode, &mut pu);
|
||||
predict_block(&self.rec_v, cx0, cy0, 8, mode, &mut pv);
|
||||
let e = Self::sse(&pu, &src_u) + Self::sse(&pv, &src_v);
|
||||
if e < uv_best {
|
||||
uv_best = e;
|
||||
uv_mode = mode;
|
||||
pred_u = pu;
|
||||
pred_v = pv;
|
||||
}
|
||||
}
|
||||
let mut u_blocks = Vec::with_capacity(4);
|
||||
let mut v_blocks = Vec::with_capacity(4);
|
||||
for (plane_src, pred, blocks, is_u) in [(&src_u, &pred_u, &mut u_blocks, true), (&src_v, &pred_v, &mut v_blocks, false)] {
|
||||
for b in 0..4 {
|
||||
let mut residual = [0i32; 16];
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c;
|
||||
residual[r * 4 + c] = plane_src[i] - i32::from(pred[i]);
|
||||
}
|
||||
}
|
||||
let (quantized, dequantized) = Self::code_block(&residual, self.q.uv_dc, self.q.uv_ac, true);
|
||||
let recon = idct(&dequantized);
|
||||
let (bx, by) = (cx0 + (b & 1) * 4, cy0 + (b >> 1) * 4);
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c;
|
||||
let value = (i32::from(pred[i]) + recon[r * 4 + c]).clamp(0, 255) as u8;
|
||||
if is_u {
|
||||
self.rec_u.set(bx + c, by + r, value);
|
||||
} else {
|
||||
self.rec_v.set(bx + c, by + r, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
blocks.push(Block::from_raster(&quantized, 0));
|
||||
}
|
||||
}
|
||||
|
||||
let skip = y2.as_ref().is_none_or(|b| !b.has_coeffs())
|
||||
&& y_blocks.iter().all(|b| !b.has_coeffs())
|
||||
&& u_blocks.iter().all(|b| !b.has_coeffs())
|
||||
&& v_blocks.iter().all(|b| !b.has_coeffs());
|
||||
self.mbs.push(Macroblock { y_mode, b_modes, uv_mode, y2, y: y_blocks, u: u_blocks, v: v_blocks, skip });
|
||||
}
|
||||
|
||||
/// Runs `f` over every token bool of the frame in coding order, with the non-zero contexts
|
||||
/// maintained exactly as the decoder does (RFC 6386 §13.3; skipped macroblocks reset them,
|
||||
/// leaving Y2 alone for `B_PRED` macroblocks).
|
||||
fn walk_tokens(&self, mut f: impl FnMut(&Block, usize, usize), mut on_mb: impl FnMut(usize, &Macroblock)) {
|
||||
let mut above: Vec<Ctx> = vec![[0; 9]; self.mb_cols];
|
||||
for mby in 0..self.mb_rows {
|
||||
let mut left: Ctx = [0; 9];
|
||||
for (mbx, a) in above.iter_mut().enumerate() {
|
||||
let mb = &self.mbs[mby * self.mb_cols + mbx];
|
||||
on_mb(mby * self.mb_cols + mbx, mb);
|
||||
if mb.skip {
|
||||
for i in 0..8 {
|
||||
left[i] = 0;
|
||||
a[i] = 0;
|
||||
}
|
||||
if mb.y_mode != B_PRED {
|
||||
left[8] = 0;
|
||||
a[8] = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(y2) = &mb.y2 {
|
||||
let ctx = usize::from(left[8] + a[8]);
|
||||
f(y2, TYPE_Y2, ctx);
|
||||
let t = u8::from(y2.has_coeffs());
|
||||
left[8] = t;
|
||||
a[8] = t;
|
||||
}
|
||||
let ty = if mb.y2.is_some() { TYPE_Y_AFTER_Y2 } else { TYPE_Y_WITH_DC };
|
||||
for (i, b) in mb.y.iter().enumerate() {
|
||||
let (ai, li) = (i & 3, i >> 2);
|
||||
let ctx = usize::from(left[li] + a[ai]);
|
||||
f(b, ty, ctx);
|
||||
let t = u8::from(b.has_coeffs());
|
||||
left[li] = t;
|
||||
a[ai] = t;
|
||||
}
|
||||
for (base, blocks) in [(4usize, &mb.u), (6usize, &mb.v)] {
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
let (ai, li) = (base + (i & 1), base + (i >> 1));
|
||||
let ctx = usize::from(left[li] + a[ai]);
|
||||
f(b, TYPE_UV, ctx);
|
||||
let t = u8::from(b.has_coeffs());
|
||||
left[li] = t;
|
||||
a[ai] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Token probabilities for this frame: the defaults, replaced where the frame's own branch
|
||||
/// counts give a clearly better estimate (sent as updates in the header).
|
||||
fn choose_probs(&self, stats: &Stats) -> [[[[u8; 11]; 3]; 8]; 4] {
|
||||
let mut probs = DEFAULT_COEFF_PROBS;
|
||||
for (t, plane) in probs.iter_mut().enumerate() {
|
||||
for (b, band) in plane.iter_mut().enumerate() {
|
||||
for (c, ctx) in band.iter_mut().enumerate() {
|
||||
for (n, p) in ctx.iter_mut().enumerate() {
|
||||
let [zeros, ones] = stats.node[t][b][c][n];
|
||||
let total = zeros + ones;
|
||||
if total < 24 {
|
||||
continue;
|
||||
}
|
||||
// Probability of a 0 branch, kept inside 1..=255.
|
||||
let est = ((u64::from(zeros) * 256 + u64::from(total) / 2) / u64::from(total)).clamp(1, 255) as u8;
|
||||
// Only worth the update bits when the estimate moves the coding cost.
|
||||
let cost = |pr: u8| -> f64 {
|
||||
let p0 = f64::from(pr) / 256.0;
|
||||
-(f64::from(zeros) * p0.log2() + f64::from(ones) * (1.0 - p0).log2())
|
||||
};
|
||||
let update_bits = 8.0 + 1.0;
|
||||
if cost(*p) - cost(est) > update_bits * 2.0 {
|
||||
*p = est;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
probs
|
||||
}
|
||||
|
||||
fn write_frame(&self, probs: &[[[[u8; 11]; 3]; 8]; 4], loop_filter_level: u8, qi: u8) -> Vec<u8> {
|
||||
// First partition: frame header, then per-macroblock prediction records.
|
||||
let mut h = BoolEncoder::new();
|
||||
h.put_flag(false); // color_space: BT.601-like
|
||||
h.put_flag(false); // clamping_type: the decoder clamps
|
||||
h.put_flag(false); // segmentation_enabled
|
||||
h.put_flag(false); // filter_type: normal
|
||||
h.put_literal(6, u32::from(loop_filter_level.min(63)));
|
||||
h.put_literal(3, 0); // sharpness_level
|
||||
h.put_flag(false); // loop_filter_adj_enable
|
||||
h.put_literal(2, 0); // one token partition
|
||||
h.put_literal(7, u32::from(qi.min(127))); // y_ac_qi
|
||||
for _ in 0..5 {
|
||||
h.put_flag(false); // no quantizer deltas
|
||||
}
|
||||
h.put_flag(true); // refresh_entropy_probs (meaningless for a single frame)
|
||||
for t in 0..4 {
|
||||
for b in 0..8 {
|
||||
for c in 0..3 {
|
||||
for n in 0..11 {
|
||||
let changed = probs[t][b][c][n] != DEFAULT_COEFF_PROBS[t][b][c][n];
|
||||
h.put(COEFF_UPDATE_PROBS[t][b][c][n], changed);
|
||||
if changed {
|
||||
h.put_literal(8, u32::from(probs[t][b][c][n]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.put_flag(true); // mb_no_skip_coeff
|
||||
let skipped = self.mbs.iter().filter(|m| m.skip).count() as u64;
|
||||
let total = self.mbs.len().max(1) as u64;
|
||||
let prob_skip_false = (((total - skipped) * 256 + total / 2) / total).clamp(1, 255) as u8;
|
||||
h.put_literal(8, u32::from(prob_skip_false));
|
||||
|
||||
for mby in 0..self.mb_rows {
|
||||
for mbx in 0..self.mb_cols {
|
||||
let mb = &self.mbs[mby * self.mb_cols + mbx];
|
||||
h.put(prob_skip_false, mb.skip);
|
||||
h.put_tree(KF_YMODE_TREE, &KF_YMODE_PROB, mb.y_mode);
|
||||
if mb.y_mode == B_PRED {
|
||||
for b in 0..16 {
|
||||
let above = if b >= 4 {
|
||||
mb.b_modes[b - 4]
|
||||
} else if mby == 0 {
|
||||
B_DC_PRED
|
||||
} else {
|
||||
self.sub_mode_of(&self.mbs[(mby - 1) * self.mb_cols + mbx], b + 12)
|
||||
};
|
||||
let left = if b & 3 != 0 {
|
||||
mb.b_modes[b - 1]
|
||||
} else if mbx == 0 {
|
||||
B_DC_PRED
|
||||
} else {
|
||||
self.sub_mode_of(&self.mbs[mby * self.mb_cols + mbx - 1], b + 3)
|
||||
};
|
||||
h.put_tree(BMODE_TREE, &KF_BMODE_PROBS[usize::from(above)][usize::from(left)], mb.b_modes[b]);
|
||||
}
|
||||
}
|
||||
h.put_tree(UV_MODE_TREE, &KF_UV_MODE_PROB, mb.uv_mode);
|
||||
}
|
||||
}
|
||||
let first = h.finish();
|
||||
|
||||
// Token partition.
|
||||
let mut t = BoolEncoder::new();
|
||||
self.walk_tokens(
|
||||
|block, ty, ctx0| {
|
||||
for_each_token_bool(block, ty, ctx0, |ev| match ev {
|
||||
Event::Node(ty, band, ctx, node, bit) => t.put(probs[ty][band][ctx][node], bit),
|
||||
Event::Extra(cat, bits) => {
|
||||
let p = PCAT[cat];
|
||||
for (i, &prob) in p.iter().enumerate() {
|
||||
let shift = p.len() - 1 - i;
|
||||
t.put(prob, (bits >> shift) & 1 != 0);
|
||||
}
|
||||
}
|
||||
Event::Sign(negative) => t.put_flag(negative),
|
||||
});
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
let tokens = t.finish();
|
||||
|
||||
// Frame tag, start code, dimensions, partitions.
|
||||
let mut out = Vec::with_capacity(10 + first.len() + tokens.len());
|
||||
let size = (first.len() as u32).min((1 << 19) - 1);
|
||||
// Bit 0 = 0: key frame; bits 1-3 = 0: version 0; bit 4: show_frame; then the size.
|
||||
let tag: u32 = (1 << 4) | (size << 5);
|
||||
out.extend_from_slice(&tag.to_le_bytes()[..3]);
|
||||
out.extend_from_slice(&[0x9d, 0x01, 0x2a]);
|
||||
out.extend_from_slice(&(self.src.width as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(self.src.height as u16).to_le_bytes());
|
||||
out.extend_from_slice(&first);
|
||||
out.extend_from_slice(&tokens);
|
||||
out
|
||||
}
|
||||
|
||||
/// The sub-block mode context a neighbouring macroblock provides for sub-block `b` (an
|
||||
/// index into the neighbour's 16 sub-blocks): its own sub-block mode when it is `B_PRED`,
|
||||
/// else the sub-block equivalent of its whole-block mode (RFC 6386 §11.3).
|
||||
fn sub_mode_of(&self, mb: &Macroblock, b: usize) -> u8 {
|
||||
match mb.y_mode {
|
||||
B_PRED => mb.b_modes[b.min(15)],
|
||||
V_PRED => B_VE_PRED,
|
||||
H_PRED => B_HE_PRED,
|
||||
TM_PRED => B_TM_PRED,
|
||||
_ => B_DC_PRED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes `yuv` as one VP8 key frame (the payload of a WebP `VP8 ` chunk).
|
||||
pub fn encode_frame(yuv: &Yuv, params: Params) -> Result<Vec<u8>, Vp8Error> {
|
||||
if yuv.width == 0 || yuv.height == 0 || yuv.width > MAX_DIMENSION || yuv.height > MAX_DIMENSION {
|
||||
return Err(Vp8Error::Dimensions);
|
||||
}
|
||||
let (w, h) = (yuv.width as usize, yuv.height as usize);
|
||||
if yuv.y.len() != w * h || yuv.u.len() != w.div_ceil(2) * h.div_ceil(2) || yuv.v.len() != yuv.u.len() {
|
||||
return Err(Vp8Error::Planes);
|
||||
}
|
||||
let qi = quantizer_index(params.quality);
|
||||
let mut enc = Encoder::new(yuv, qi);
|
||||
for mby in 0..enc.mb_rows {
|
||||
for mbx in 0..enc.mb_cols {
|
||||
enc.encode_macroblock(mbx, mby);
|
||||
}
|
||||
}
|
||||
// Pass 1 over the tokens: branch statistics → this frame's probabilities.
|
||||
let mut stats = Stats::new();
|
||||
enc.walk_tokens(
|
||||
|block, ty, ctx0| {
|
||||
for_each_token_bool(block, ty, ctx0, |ev| {
|
||||
if let Event::Node(ty, band, ctx, node, bit) = ev {
|
||||
stats.node[ty][band][ctx][node][usize::from(bit)] += 1;
|
||||
}
|
||||
});
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
let probs = enc.choose_probs(&stats);
|
||||
// The loop filter smooths block edges in proportion to the quantizer; none at the finest.
|
||||
let loop_filter_level = if qi == 0 { 0 } else { (u32::from(qi) / 2 + 4).min(63) as u8 };
|
||||
Ok(enc.write_frame(&probs, loop_filter_level, qi))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quantizer_curve_is_monotone_and_spans_the_range() {
|
||||
assert_eq!(quantizer_index(100), 0);
|
||||
assert_eq!(quantizer_index(0), 127);
|
||||
let mut last = 0;
|
||||
for q in (0..=100).rev() {
|
||||
let qi = quantizer_index(q);
|
||||
assert!(qi >= last, "quality {q}");
|
||||
last = qi;
|
||||
}
|
||||
assert!((8..=14).contains(&quantizer_index(90)), "{}", quantizer_index(90));
|
||||
assert!((24..=32).contains(&quantizer_index(75)), "{}", quantizer_index(75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_cover_every_magnitude() {
|
||||
for v in [0, 1, 2, 3, 4, 5, 6, 7, 10, 11, 18, 19, 34, 35, 66, 67, 1000, 2048] {
|
||||
let (tok, extra) = token_of(v);
|
||||
let base = match extra {
|
||||
None => i32::from(tok),
|
||||
Some((c, bits)) => {
|
||||
assert!(bits < (1 << PCAT[c].len()), "{v}");
|
||||
CAT_BASE[c] + bits as i32
|
||||
}
|
||||
};
|
||||
assert_eq!(base, v);
|
||||
assert_eq!(token_of(-v).0, tok);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_eob_is_after_the_last_nonzero() {
|
||||
let mut raster = [0i32; 16];
|
||||
let b = Block::from_raster(&raster, 1);
|
||||
assert_eq!(b.eob, 1);
|
||||
assert!(!b.has_coeffs());
|
||||
raster[ZIGZAG[5]] = 3;
|
||||
let b = Block::from_raster(&raster, 0);
|
||||
assert_eq!(b.eob, 6);
|
||||
assert!(b.has_coeffs());
|
||||
raster[ZIGZAG[15]] = -1;
|
||||
assert_eq!(Block::from_raster(&raster, 0).eob, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_dimensions() {
|
||||
let y = Yuv { width: 0, height: 4, y: vec![], u: vec![], v: vec![] };
|
||||
assert_eq!(encode_frame(&y, Params { quality: 80 }).unwrap_err(), Vp8Error::Dimensions);
|
||||
let y = Yuv { width: 4, height: 4, y: vec![0; 15], u: vec![0; 4], v: vec![0; 4] };
|
||||
assert_eq!(encode_frame(&y, Params { quality: 80 }).unwrap_err(), Vp8Error::Planes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_header_is_well_formed() {
|
||||
let rgb: Vec<u8> = (0..(20 * 13 * 3)).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let yuv = Yuv::from_rgb(&rgb, 20, 13, 3);
|
||||
let frame = encode_frame(&yuv, Params { quality: 75 }).unwrap();
|
||||
assert_eq!(frame[0] & 1, 0, "key frame");
|
||||
assert_eq!((frame[0] >> 4) & 1, 1, "shown");
|
||||
assert_eq!(&frame[3..6], &[0x9d, 0x01, 0x2a]);
|
||||
assert_eq!(u16::from_le_bytes([frame[6], frame[7]]) & 0x3fff, 20);
|
||||
assert_eq!(u16::from_le_bytes([frame[8], frame[9]]) & 0x3fff, 13);
|
||||
let first_size = (u32::from(frame[0]) | (u32::from(frame[1]) << 8) | (u32::from(frame[2]) << 16)) >> 5;
|
||||
assert!((10 + first_size as usize) < frame.len());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 537 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user