diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 231e754..052efa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,18 +6,13 @@ on: tags: ['v*'] pull_request: workflow_dispatch: - inputs: - tag: - description: 'Tag to create and release (e.g. v0.1.0)' - required: true - type: string env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 jobs: - # ── Tests ───────────────────────────────────────────────────────────── + # ── Tests + lints ───────────────────────────────────────────────────── # Runs on every push and PR. Gates the release. test: name: Test @@ -25,8 +20,58 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy -p pixfix --all-targets --features tui -- -D warnings + - run: cargo test --locked --features tui + + # ── Coverage ────────────────────────────────────────────────────────── + # cargo-llvm-cov over the CLI crate (pixfix needs a GUI stack and has no + # tests). Upload failures don't fail CI — coverage is a signal, not a gate. + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: taiki-e/install-action@cargo-llvm-cov - uses: Swatinem/rust-cache@v2 - - run: cargo test --locked + with: + key: coverage + - run: cargo llvm-cov -p pixfix --features tui --locked --lcov --output-path lcov.info + - uses: codecov/codecov-action@v5 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + # ── pixfix compile check ────────────────────────────────────────────── + # The desktop app is a workspace member the root test job never builds; + # without this, PRs cannot catch desktop-app breakage. + check-pixfix: + name: Check pixfix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: desktop-check + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev + - run: cargo clippy -p pixfix-desktop --all-targets --locked -- -D warnings # ── CLI binaries ────────────────────────────────────────────────────── # Pure Rust, no system deps. Runs on push to master, tags, and manual dispatch. @@ -58,23 +103,27 @@ jobs: if: runner.os != 'Windows' run: | cd target/${{ matrix.target }}/release - tar czf "$GITHUB_WORKSPACE/normalize-pixelart-${{ matrix.name }}.tar.gz" normalize-pixelart + tar czf "$GITHUB_WORKSPACE/pixfix-${{ matrix.name }}.tar.gz" pixfix - name: Package (Windows) if: runner.os == 'Windows' shell: bash run: | cd target/${{ matrix.target }}/release - 7z a "$GITHUB_WORKSPACE/normalize-pixelart-${{ matrix.name }}.zip" normalize-pixelart.exe + 7z a "$GITHUB_WORKSPACE/pixfix-${{ matrix.name }}.zip" pixfix.exe - uses: actions/upload-artifact@v4 with: name: cli-${{ matrix.name }} - path: normalize-pixelart-${{ matrix.name }}.* + path: pixfix-${{ matrix.name }}.* if-no-files-found: error # ── Tauri desktop app ───────────────────────────────────────────────── # Needs system libs on Linux, bun for frontend, Tauri CLI for bundling. + # The frontend bundle is built by tauri.conf.json's beforeBuildCommand; + # Tauri executes hooks from the cargo workspace root, hence the + # repo-root-relative paths in the config. No separate frontend step or + # config override needed. build-tauri: name: Tauri / ${{ matrix.name }} if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' @@ -108,17 +157,13 @@ jobs: librsvg2-dev \ patchelf - - name: Build frontend - run: bun build pixfix/ui/src/app.ts --outfile pixfix/ui/app.js --minify - - name: Build Tauri app shell: bash - working-directory: pixfix + working-directory: desktop run: | bunx @tauri-apps/cli@^2 build \ --target ${{ matrix.target }} \ - --bundles ${{ matrix.bundles }} \ - --config '{"build":{"beforeBuildCommand":""}}' + --bundles ${{ matrix.bundles }} - name: Collect bundles shell: bash @@ -136,31 +181,40 @@ jobs: path: dist/* if-no-files-found: error + # ── crates.io ───────────────────────────────────────────────────────── + # Publishes the CLI crate on version tags. Skips quietly when the + # CARGO_REGISTRY_TOKEN secret is not configured. + publish-crate: + name: Publish to crates.io + if: startsWith(github.ref, 'refs/tags/v') + needs: [test, check-pixfix] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ -z "$CARGO_REGISTRY_TOKEN" ]; then + echo "CARGO_REGISTRY_TOKEN not set; skipping crates.io publish" + exit 0 + fi + cargo publish -p pixfix --locked + # ── GitHub Release ──────────────────────────────────────────────────── - # Runs on version tags or manual dispatch with a tag input. + # Tag pushes release automatically; manual re-releases from an existing + # CI run live in release.yml (the only other release path). release: name: Release - if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.tag != '') - needs: [test, build-cli, build-tauri] + if: startsWith(github.ref, 'refs/tags/v') + needs: [test, check-pixfix, build-cli, build-tauri] runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 - - name: Create and push tag - if: github.event_name == 'workflow_dispatch' - env: - GH_TOKEN: ${{ github.token }} - run: | - TAG="${{ inputs.tag }}" - if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then - echo "Tag $TAG already exists on remote, skipping creation" - else - git tag "$TAG" - git push origin "$TAG" - fi - - uses: actions/download-artifact@v4 with: path: artifacts @@ -173,7 +227,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - TAG="${{ inputs.tag || github.ref_name }}" - gh release create "$TAG" artifacts/* \ + gh release create "${{ github.ref_name }}" artifacts/* \ --generate-notes \ - --title "$TAG" + --title "${{ github.ref_name }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..b6aa322 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +name: Docs + +on: + push: + branches: [master] + paths: ['docs/**', '.github/workflows/docs.yml'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + # docs/ is deployed as-is: the wasm demo bundle (docs/demo/pkg) is + # prebuilt and committed. Regenerate with: + # wasm-pack build wasm --target web --release --out-dir ../docs/demo/pkg + - uses: actions/upload-pages-artifact@v3 + with: + path: docs + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 4c1938f..342d886 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ test_images/ !screenshots/** # Allow Tauri app icons -!pixfix/icons/** +!desktop/icons/** # Editor *.swp @@ -25,3 +25,9 @@ test_images/ # macOS .DS_Store + +# Built frontend bundle (bun build output; CI and tauri hooks rebuild it) +desktop/ui/app.js + +# Real-image regression corpus is tracked despite the global image ignore +!tests/corpus/*.png diff --git a/Cargo.lock b/Cargo.lock index 77c1e35..3a8b0ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,6 +162,21 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atk" version = "0.18.2" @@ -367,6 +382,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "built" version = "0.8.0" @@ -1007,6 +1033,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1254,6 +1286,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fax" version = "0.2.6" @@ -1332,6 +1370,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1958,7 +2005,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -2768,30 +2815,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] -name = "normalize-pixelart" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "crossterm 0.28.1", - "dirs", - "glob", - "image", - "indicatif", - "palette", - "rand 0.8.5", - "ratatui", - "ratatui-image", - "rayon", - "serde", - "serde_json", - "shellexpand", - "thiserror 2.0.18", - "toml 0.8.2", - "tracing", - "tracing-subscriber", - "ureq", -] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "nu-ansi-term" @@ -3435,9 +3462,39 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" name = "pixfix" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "anyhow", + "assert_cmd", + "clap", + "crossterm 0.28.1", + "dirs", + "glob", + "image", + "indicatif", + "palette", + "predicates", + "rand 0.9.2", + "rand_chacha 0.9.0", + "ratatui", + "ratatui-image", + "rayon", + "serde", + "serde_json", + "shellexpand", + "tempfile", + "thiserror 2.0.18", + "toml 0.8.2", + "tracing", + "tracing-subscriber", + "ureq", +] + +[[package]] +name = "pixfix-desktop" +version = "0.1.0" +dependencies = [ "image", - "normalize-pixelart", + "parking_lot", + "pixfix", "serde", "serde_json", "tauri", @@ -3445,6 +3502,16 @@ dependencies = [ "tauri-plugin-dialog", ] +[[package]] +name = "pixfix-wasm" +version = "0.1.0" +dependencies = [ + "image", + "js-sys", + "pixfix", + "wasm-bindgen", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3526,6 +3593,36 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5117,6 +5214,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -5159,6 +5269,12 @@ dependencies = [ "libc", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "termwiz" version = "0.23.3" @@ -5813,6 +5929,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 92c7e5d..47b2053 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,59 +1,98 @@ [workspace] -members = [".", "pixfix"] +members = [".", "desktop", "wasm"] + +[workspace.dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 [package] -name = "normalize-pixelart" +name = "pixfix" version = "0.1.0" edition = "2021" description = "Normalize AI-generated pixel art into clean, grid-aligned game assets" license = "MIT" +repository = "https://github.com/lovelaced/pixfix" +homepage = "https://lovelaced.github.io/pixfix/" +readme = "README.md" +keywords = ["pixel-art", "sprites", "gamedev", "image", "ai"] +categories = ["multimedia::images", "command-line-utilities", "graphics"] +exclude = ["screenshots/", "docs/", "tests/corpus/", ".github/"] [features] -default = ["lospec"] -lospec = ["dep:ureq", "dep:serde_json", "dep:dirs"] -tui = ["dep:ratatui", "dep:ratatui-image", "dep:crossterm"] +default = ["cli", "lospec", "parallel"] +# Command-line front end: arg parsing, batch/glob resolution, progress bars, +# stdin/stdout piping, atomic file writes. The core pipeline never needs it — +# leaving it off is what lets the library build for wasm32. +cli = ["dep:clap", "dep:indicatif", "dep:glob", "dep:tempfile", "dep:tracing-subscriber"] +# Rayon-parallel hot paths. Off (e.g. on wasm32) the same call sites compile +# against serial stand-ins — see src/parallel.rs. +parallel = ["dep:rayon"] +lospec = ["dep:ureq", "dep:dirs"] +tui = ["dep:ratatui", "dep:ratatui-image", "dep:crossterm", "dep:shellexpand"] + +[[bin]] +name = "pixfix" +path = "src/main.rs" +required-features = ["cli"] [dependencies] # Image I/O and manipulation -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } +image = { workspace = true, features = ["jpeg", "gif", "webp", "bmp"] } # Color science palette = { version = "0.7", features = ["std"] } -# CLI -clap = { version = "4", features = ["derive", "wrap_help"] } +# CLI (cli feature) +clap = { version = "4", features = ["derive", "wrap_help"], optional = true } -# Random (for k-means initialization) -rand = "0.8" +# Random (for k-means initialization; ChaCha8 for cross-platform +# reproducibility — StdRng's algorithm is not stable across rand versions). +# Default features off: every RNG here is caller-seeded, so we never need +# OS entropy (getrandom), which also doesn't build on wasm32-unknown-unknown. +rand = { version = "0.9", default-features = false } +rand_chacha = "0.9" -# Parallelism -rayon = "1" +# Parallelism (parallel feature) +rayon = { version = "1", optional = true } # Error handling anyhow = "1" thiserror = "2" -# Config -serde = { version = "1", features = ["derive"] } +# Config + JSON reports +serde = { workspace = true } +serde_json = { workspace = true } toml = "0.8" -# Batch processing -indicatif = "0.17" -glob = "0.3" +# Batch processing (cli feature) +indicatif = { version = "0.17", optional = true } +glob = { version = "0.3", optional = true } + +# Path expansion (TUI file prompts only) +shellexpand = { version = "3", optional = true } -# Path expansion -shellexpand = "3" +# Atomic output writes (cli feature) +tempfile = { version = "3", optional = true } # Logging tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Lospec API (optional) ureq = { version = "3", optional = true } -serde_json = { version = "1", optional = true } dirs = { version = "6", optional = true } # TUI (optional) ratatui = { version = "0.30", optional = true } ratatui-image = { version = "10.0", optional = true, default-features = false, features = ["crossterm", "image-defaults"] } crossterm = { version = "0.28", optional = true } + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" diff --git a/README.md b/README.md index fb740aa..58bc226 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ **Clean up AI-generated pixel art.**
Snap to grid. Remove AA fuzz. Reduce to a palette. Remove backgrounds. -[![CI](https://github.com/lovelaced/normalize-pixelart/actions/workflows/ci.yml/badge.svg)](https://github.com/lovelaced/normalize-pixelart/actions/workflows/ci.yml) +[![CI](https://github.com/lovelaced/pixfix/actions/workflows/ci.yml/badge.svg)](https://github.com/lovelaced/pixfix/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/lovelaced/pixfix/branch/master/graph/badge.svg)](https://codecov.io/gh/lovelaced/pixfix) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Release](https://img.shields.io/github/v/release/lovelaced/normalize-pixelart?include_prereleases&label=release)](https://github.com/lovelaced/normalize-pixelart/releases) +[![Release](https://img.shields.io/github/v/release/lovelaced/pixfix?include_prereleases&label=release)](https://github.com/lovelaced/pixfix/releases) -[Download](#install) · [Features](#features) · [CLI Reference](#cli-reference) · [How It Works](#how-it-works) +[Docs](https://lovelaced.github.io/pixfix/) (with an in-browser demo) · [Download](#install) · [Features](#features) · [CLI Reference](#cli-reference) · [How It Works](#how-it-works) @@ -85,10 +86,10 @@ Download the latest release for your platform: | Platform | Download | |:---------|:---------| -| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/lovelaced/normalize-pixelart/releases/latest) | -| **macOS** (Intel) | [`.dmg`](https://github.com/lovelaced/normalize-pixelart/releases/latest) | -| **Windows** | [`.msi`](https://github.com/lovelaced/normalize-pixelart/releases/latest) · [`.exe` installer](https://github.com/lovelaced/normalize-pixelart/releases/latest) | -| **Linux** | [`.deb`](https://github.com/lovelaced/normalize-pixelart/releases/latest) · [`.AppImage`](https://github.com/lovelaced/normalize-pixelart/releases/latest) | +| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/lovelaced/pixfix/releases/latest) | +| **macOS** (Intel) | [`.dmg`](https://github.com/lovelaced/pixfix/releases/latest) | +| **Windows** | [`.msi`](https://github.com/lovelaced/pixfix/releases/latest) · [`.exe` installer](https://github.com/lovelaced/pixfix/releases/latest) | +| **Linux** | [`.deb`](https://github.com/lovelaced/pixfix/releases/latest) · [`.AppImage`](https://github.com/lovelaced/pixfix/releases/latest) | @@ -96,14 +97,14 @@ Download the latest release for your platform: ```bash # From source (requires Rust 1.70+) -git clone https://github.com/lovelaced/normalize-pixelart.git -cd normalize-pixelart +git clone https://github.com/lovelaced/pixfix.git +cd pixfix cargo build --release -# Binary at target/release/normalize-pixelart +# Binary at target/release/pixfix ``` -Pre-built CLI binaries for all platforms are also available on the [Releases](https://github.com/lovelaced/normalize-pixelart/releases) page. +Pre-built CLI binaries for all platforms are also available on the [Releases](https://github.com/lovelaced/pixfix/releases) page. --- @@ -136,22 +137,28 @@ Pre-built CLI binaries for all platforms are also available on the [Releases](ht ```bash # Auto-detect grid, normalize -normalize-pixelart process input.png output.png +pixfix process input.png output.png # Force grid size, snap to PICO-8 palette -normalize-pixelart process input.png output.png --grid-size 4 --palette pico-8 +pixfix process input.png output.png --grid-size 4 --palette pico-8 # Remove background -normalize-pixelart process input.png output.png --grid-size 4 --remove-bg +pixfix process input.png output.png --grid-size 4 --remove-bg + +# Chroma key: make a color transparent everywhere (green-screen style) +pixfix process input.png output.png --chroma-key 00FF00 + +# Inspect without writing anything +pixfix analyze input.png --json # Batch process a folder -normalize-pixelart batch sprites/ output/ --grid-size 4 --palette sweetie-16 +pixfix batch sprites/ output/ --grid-size 4 --palette sweetie-16 # Auto-split an AI sprite sheet -normalize-pixelart sheet ai_sheet.png --output-dir sprites/ +pixfix sheet ai_sheet.png --output-dir sprites/ # Interactive terminal editor (Sixel/halfblock preview) -normalize-pixelart tui input.png +pixfix tui input.png ``` --- @@ -225,14 +232,26 @@ For each pixel, examines its 8-connected neighbors. If the pixel lies "between" ## CLI Reference +`-h` shows a curated set of flags — the ones that change results, like +`--grid-size` and `--coarsen`. Tuning flags for detection thresholds and +edge cases only appear in `--help`, which is always complete. + ### `process` — Normalize a single image ``` -normalize-pixelart process [OPTIONS] [OUTPUT] +pixfix process [OPTIONS] [OUTPUT] ``` Output defaults to `_normalized.png`. +**Animated GIFs.** A GIF with more than one frame is normalized as one +animation, not as independent images: the grid is detected once on the first +frame, the background color is resolved once, and a single palette is +extracted from all frames together, so nothing drifts or flickers between +frames. Each frame is then snapped with those shared settings. Per-frame +timing is preserved and the output is always an animated GIF +(`_normalized.gif`). Static GIFs take the normal single-image path. +
All options
@@ -241,10 +260,29 @@ Output defaults to `_normalized.png`. | Flag | Description | |------|-------------| -| `--grid-size ` | Override auto-detected grid size | -| `--grid-phase ` | Override grid phase offset | +| `--grid-size ` | Override auto-detected grid size (fractional values like `10.667` work) | +| `--grid-phase ` | Override grid phase offset (requires `--grid-size`) | | `--no-grid-detect` | Skip grid detection (requires `--grid-size`) | -| `--max-grid-candidate ` | Max grid size to test (default: 32) | +| `--max-grid-candidate ` | Max pitch to test (default: scales with image size) | +| `--coarsen ` | Multiply the final grid pitch by an integer (see below) | +| `--min-confidence <0-1>` | Detection confidence floor (default: 0.35); below it the pipeline declines to snap and reports its best guess. Lower it for heavily anti-aliased images | + +**`--coarsen`, exactly:** detection finds the *render quantum* — the grid the +generator physically painted on (often ~2px at 1024). The intended *artistic* +resolution is usually a multiple of that: a 1024px image at 2px pitch is a +512x512 sprite, nothing like 16-bit-era art, while `--coarsen 2` snaps the +same image at 4px for an honest 256x256. The multiplier applies to the +auto-detected pitch or to `--grid-size` (so `--grid-size 2.714 --coarsen 2` +snaps at 5.428); phase is preserved, so coarse blocks stay aligned with the +fine grid; it does nothing when detection declines (there is no pitch to +multiply); and reported grid values (`--json` included) show the multiplied +pitch, while `grid_best_guess` diagnostics keep the raw detection. + +Detection handles fractional pitch — AI upscales often render, say, 48 cells +across 512px (10.667px per cell), and the comb search finds that directly. +When confidence is too low to trust, the pipeline reports its best guess and +leaves the image unsnapped instead of inventing a grid; pass `--grid-size` +to force one. **Downscale** @@ -264,6 +302,18 @@ Output defaults to `_normalized.png`. | `--lospec ` | Fetch palette from [Lospec](https://lospec.com) by slug | | `--colors ` | Auto-extract N colors via k-means | | `--no-quantize` | Skip quantization | +| `--seed ` | RNG seed — same input and seed always produce identical bytes | +| `--flatten-dither` | Turn off dither preservation (detected dither pairs are normally pinned so quantization can't flatten them) | + +The palette source flags are mutually exclusive. Extracted palettes use each +cluster's most representative real color, never invented averages. + +**Anti-aliasing** + +| Flag | Description | +|------|-------------| +| `--aa-threshold <0-1>` | Enable AA removal; higher = more aggressive (off by default) | +| `--aa-passes ` | Max removal passes for wide AA ramps (default: 3) | **Background** @@ -274,30 +324,63 @@ Output defaults to `_normalized.png`. | `--bg-threshold <0-1>` | Border detection threshold (default: 0.4) | | `--bg-tolerance ` | Color tolerance in OKLAB (default: 0.05) | | `--no-flood-fill` | Global replacement instead of flood-fill | +| `--chroma-key ` | Green-screen removal: this color becomes transparent everywhere, interior regions included; repeat for multiple keys, combinable with `--remove-bg` | +| `--chroma-tolerance ` | Chroma key match tolerance in OKLAB (default: 0.05) | **Output** | Flag | Description | |------|-------------| -| `--target-width ` | Output width | -| `--target-height ` | Output height | -| `--aa-threshold <0-1>` | Enable AA removal (off by default) | -| `--overwrite` | Overwrite existing output | +| `--target-width ` / `--target-height ` | Explicit output size | +| `--logical-size` | Reduced modes: emit the true logical resolution instead of the default crisp integer re-upscale | +| `--keep-alpha` | Preserve per-pixel alpha instead of binarizing blocks to opaque/transparent | +| `--output-format ` | Output encoding; also sets the derived extension (default: png; animated inputs always produce gif) | +| `--overwrite` / `--no-overwrite` | Overwrite policy (no-clobber by default) | +| `--debug-overlay ` | Also write a diagnostic PNG: source dimmed, blocks tinted red by how contested their color vote was — a wrong pitch or phase lights up instantly | + +Derived output names always get the output format's extension — a `.jpg` +input produces `_normalized.png`. Pass `-` as input to read from stdin, or as +output to write PNG to stdout for piping.
+### `analyze` — Inspect without writing + +```bash +pixfix analyze input.png --json +``` + +Runs grid detection, background detection, and color counting with zero +writes — pitch, phase, confidence, logical size, unique colors, detected +background. When a grid is found it also probes snapping at 1x/2x/4x of the +detected pitch and reports contested-block rates per factor, suggesting a +`--coarsen` value when the content reads cleanly at a coarser artistic +resolution. Add `--sheet` to preview the auto-split sprite count. Ideal for +scripts and agents that want to look before they process. + ### `batch` — Process multiple images ``` -normalize-pixelart batch [OPTIONS] +pixfix batch [OPTIONS] ``` -`INPUT` can be a directory or glob pattern. All `process` flags available. +`INPUT` can be a directory or glob pattern (extensions match +case-insensitively). Pipeline flags from `process` apply; `--target-width`/ +`--target-height` are per-image and not available in batch. + +| Flag | Description | +|------|-------------| +| `--suffix ` | Output name suffix (default: `_normalized`; `''` for none) | +| `--preserve-dirs` | Recreate the input directory structure (recursive globs that would collide are rejected otherwise) | +| `--output-format ` | Output encoding for every file | + +One bad file never aborts the run; every file's outcome is itemized, and a +partial failure exits with code 6. ### `sheet` — Sprite sheet processing ``` -normalize-pixelart sheet [OPTIONS] [OUTPUT] +pixfix sheet [OPTIONS] [OUTPUT] ``` **Fixed grid** — specify `--tile-width` and `--tile-height` for known layouts. @@ -323,24 +406,62 @@ normalize-pixelart sheet [OPTIONS] [OUTPUT] ### `tui` — Interactive terminal editor ```bash -normalize-pixelart tui [INPUT] +pixfix tui [INPUT] ``` -Terminal UI with live image preview (Sixel/halfblock). Requires the `tui` feature (default). +Terminal UI with live image preview (Sixel/halfblock). Requires building with +the `tui` feature (`cargo install --features tui`); it is not in the default +feature set. ### `palette` — Palette utilities ```bash -normalize-pixelart palette list # Show built-in palettes -normalize-pixelart palette fetch endesga-32 # Download from Lospec -normalize-pixelart palette extract input.png -o p.hex # Extract from image +pixfix palette list # Show built-in palettes +pixfix palette fetch endesga-32 # Download from Lospec +pixfix palette extract input.png -o p.hex # Extract from image ``` +Fetched palettes are cached in the platform cache directory; `--refresh` +bypasses the cache. + +--- + +## Scripting and agents + +Every command takes a global `--json` flag that emits exactly one JSON +document on stdout (stable schema, fields always present); logs and progress +stay on stderr, as NDJSON events in JSON mode. `--quiet` silences everything +except errors. Results are deterministic: the same input and `--seed` +produce identical bytes. + +For language models there's a plain-text version of the whole manual: +[llms.txt](https://lovelaced.github.io/pixfix/llms.txt) and +[llms-full.txt](https://lovelaced.github.io/pixfix/llms-full.txt). + +```bash +pixfix analyze sprite.png --json | jq .grid.pitch_x +pixfix process sprite.png out.png --json | jq .colors_after +``` + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 2 | Usage or config error | +| 3 | Input could not be read | +| 4 | Processing failed | +| 5 | Output already exists (pass `--overwrite`) | +| 6 | Batch finished with some files failed | + --- ## Config File -Save settings in `.normalize-pixelart.toml` — CLI arguments override config values. +Save settings in `.pixfix.toml` — CLI arguments override config +values, which override the built-in defaults. An explicit `--config` path +that doesn't exist is an error; `--no-config` ignores config files entirely +(useful for scripted runs in arbitrary directories).
Example config diff --git a/pixfix/Cargo.toml b/desktop/Cargo.toml similarity index 53% rename from pixfix/Cargo.toml rename to desktop/Cargo.toml index 645e909..2d21ff4 100644 --- a/pixfix/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,18 +1,19 @@ [package] -name = "pixfix" +name = "pixfix-desktop" version = "0.1.0" edition = "2021" -description = "Desktop pixel art normalizer — Tauri frontend for normalize-pixelart" +publish = false +description = "Desktop pixel art normalizer — Tauri frontend for pixfix" license = "MIT" [build-dependencies] tauri-build = { version = "2", features = [] } [dependencies] -normalize-pixelart = { path = ".." } +pixfix = { path = ".." } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -image = { version = "0.25", default-features = false, features = ["png", "gif"] } -base64 = "0.22" +serde = { workspace = true } +serde_json = { workspace = true } +image = { workspace = true, features = ["gif"] } +parking_lot = "0.12" diff --git a/pixfix/build.rs b/desktop/build.rs similarity index 100% rename from pixfix/build.rs rename to desktop/build.rs diff --git a/pixfix/capabilities/default.json b/desktop/capabilities/default.json similarity index 100% rename from pixfix/capabilities/default.json rename to desktop/capabilities/default.json diff --git a/pixfix/gen/schemas/acl-manifests.json b/desktop/gen/schemas/acl-manifests.json similarity index 100% rename from pixfix/gen/schemas/acl-manifests.json rename to desktop/gen/schemas/acl-manifests.json diff --git a/pixfix/gen/schemas/capabilities.json b/desktop/gen/schemas/capabilities.json similarity index 100% rename from pixfix/gen/schemas/capabilities.json rename to desktop/gen/schemas/capabilities.json diff --git a/pixfix/gen/schemas/desktop-schema.json b/desktop/gen/schemas/desktop-schema.json similarity index 100% rename from pixfix/gen/schemas/desktop-schema.json rename to desktop/gen/schemas/desktop-schema.json diff --git a/pixfix/gen/schemas/macOS-schema.json b/desktop/gen/schemas/macOS-schema.json similarity index 100% rename from pixfix/gen/schemas/macOS-schema.json rename to desktop/gen/schemas/macOS-schema.json diff --git a/pixfix/icons/128x128.png b/desktop/icons/128x128.png similarity index 100% rename from pixfix/icons/128x128.png rename to desktop/icons/128x128.png diff --git a/pixfix/icons/128x128@2x.png b/desktop/icons/128x128@2x.png similarity index 100% rename from pixfix/icons/128x128@2x.png rename to desktop/icons/128x128@2x.png diff --git a/pixfix/icons/32x32.png b/desktop/icons/32x32.png similarity index 100% rename from pixfix/icons/32x32.png rename to desktop/icons/32x32.png diff --git a/pixfix/icons/icon.icns b/desktop/icons/icon.icns similarity index 100% rename from pixfix/icons/icon.icns rename to desktop/icons/icon.icns diff --git a/pixfix/icons/icon.ico b/desktop/icons/icon.ico similarity index 100% rename from pixfix/icons/icon.ico rename to desktop/icons/icon.ico diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs new file mode 100644 index 0000000..627e4d6 --- /dev/null +++ b/desktop/src/lib.rs @@ -0,0 +1,884 @@ +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +use image::codecs::gif::{GifEncoder, Repeat}; +use image::{Delay, Frame, RgbaImage}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, State}; + +use pixfix::batch::{run_batch, BatchEvent, BatchOptions, ResolvedInputs}; +use pixfix::color::lospec; +use pixfix::color::palettes::{self, ALL_PALETTES}; +use pixfix::config::parse_hex_color; +use pixfix::image_util::histogram::ColorHistogram; +use pixfix::image_util::io; +use pixfix::pipeline::{ + resolve_pipeline_config, run_pipeline, DownscaleMode, PaletteSource, PipelineConfig, + PipelineDiagnostics, PipelineOptions, +}; +use pixfix::report::FileStatus; + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct AppState { + original: Option, + processed: Option, + config: PipelineConfig, + diagnostics: Option, + unique_colors: usize, + sheet_tiles: Option>, + /// Monotonic run counter. Pipeline results are stored only if no newer + /// run started while this one was executing, so a slow older run can't + /// overwrite a newer result during slider scrubbing. + generation: u64, +} + +// --------------------------------------------------------------------------- +// Serializable types for JS communication +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ImageInfo { + width: u32, + height: u32, + grid_size: Option, + grid_pitch_y: Option, + grid_confidence: Option, + unique_colors: usize, + low_confidence_blocks: Option, + grid_scores: Vec<(f32, f32)>, + histogram: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProcessConfig { + grid_size: Option, + grid_phase_x: Option, + grid_phase_y: Option, + max_grid_candidate: Option, + no_grid_detect: bool, + downscale_mode: String, + aa_threshold: Option, + palette_name: Option, + auto_colors: Option, + custom_palette: Option>, + remove_bg: bool, + bg_color: Option, + border_threshold: Option, + no_quantize: bool, + bg_tolerance: f32, + flood_fill: bool, + #[serde(default)] + chroma_key: Option, + #[serde(default)] + coarsen: Option, + #[serde(default)] + min_confidence: Option, + output_scale: Option, + output_width: Option, + output_height: Option, +} + +impl ProcessConfig { + /// Lower into the library's [`PipelineOptions`]. The original image + /// dimensions are needed to turn `output_scale` into absolute output + /// dimensions. + fn to_options( + &self, + original_width: u32, + original_height: u32, + ) -> Result { + let downscale_mode = self.downscale_mode.parse::()?; + + // Precedence: custom palette > named palette > auto-extract. + let palette = if let Some(ref lines) = self.custom_palette { + let colors = + palettes::parse_hex_palette(&lines.join("\n")).map_err(|e| e.to_string())?; + if colors.is_empty() { + None + } else { + Some(PaletteSource::Custom { + colors, + label: None, + }) + } + } else if let Some(ref name) = self.palette_name { + Some(PaletteSource::Named(name.clone())) + } else { + self.auto_colors.map(PaletteSource::AutoExtract) + }; + + let bg_color = match self.bg_color { + Some(ref hex) => { + Some(parse_hex_color(hex).map_err(|e| format!("background color: {}", e))?) + } + None => None, + }; + + let chroma_keys_parsed = match self.chroma_key { + Some(ref hex) if !hex.trim().is_empty() => Some(vec![ + parse_hex_color(hex).map_err(|e| format!("chroma key: {}", e))? + ]), + _ => None, + }; + + // Explicit dimensions take priority over scale. + let (output_width, output_height) = + if self.output_width.is_some() || self.output_height.is_some() { + (self.output_width, self.output_height) + } else { + match self.output_scale { + Some(scale) if scale > 1 => { + (Some(original_width * scale), Some(original_height * scale)) + } + _ => (None, None), + } + }; + + Ok(PipelineOptions { + grid_size: self.grid_size, + grid_phase: match (self.grid_phase_x, self.grid_phase_y) { + (Some(x), Some(y)) => Some((x, y)), + _ => None, + }, + max_grid_candidate: self.max_grid_candidate, + no_grid_detect: self.no_grid_detect.then_some(true), + coarsen: self.coarsen.filter(|&c| c >= 1), + min_confidence: self.min_confidence, + downscale_mode: Some(downscale_mode), + keep_alpha: None, + logical_output: None, + aa_threshold: self.aa_threshold, + aa_skip: self.aa_threshold.map(|_| false), + aa_passes: None, + palette, + no_quantize: self.no_quantize.then_some(true), + seed: None, + flatten_dither: None, + bg_enabled: Some(self.remove_bg), + bg_color, + bg_border_threshold: self.border_threshold, + bg_color_tolerance: Some(self.bg_tolerance), + bg_flood_fill: Some(self.flood_fill), + chroma_keys: chroma_keys_parsed, + chroma_tolerance: None, + output_width, + output_height, + }) + } + + /// Resolve into a concrete config through the library's single assembly + /// path (pixfix has no config-file layer). + fn to_config( + &self, + original_width: u32, + original_height: u32, + ) -> Result { + let options = self.to_options(original_width, original_height)?; + resolve_pipeline_config(options, PipelineOptions::default()).map_err(|e| e.to_string()) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LospecResult { + name: String, + slug: String, + num_colors: usize, + colors: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProcessResult { + width: u32, + height: u32, + grid_size: Option, + grid_pitch_y: Option, + grid_confidence: Option, + unique_colors: usize, + low_confidence_blocks: Option, + grid_scores: Vec<(f32, f32)>, + histogram: Vec, +} + +#[derive(Serialize, Clone)] +struct ColorEntry { + hex: String, + r: u8, + g: u8, + b: u8, + percent: f64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PaletteInfo { + name: String, + slug: String, + num_colors: usize, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn build_histogram_entries(img: &RgbaImage, top_n: usize) -> (Vec, usize) { + let hist = ColorHistogram::from_image(img); + let total = hist.total_pixels() as f64; + let unique = hist.unique_colors(); + let entries = hist + .top_n(top_n) + .into_iter() + .map(|(rgba, count)| { + let [r, g, b, _] = rgba.0; + ColorEntry { + hex: format!("#{:02X}{:02X}{:02X}", r, g, b), + r, + g, + b, + percent: (count as f64 / total) * 100.0, + } + }) + .collect(); + (entries, unique) +} + +fn encode_png(img: &RgbaImage) -> Result, String> { + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + Ok(buf.into_inner()) +} + +fn build_gif_bytes( + tiles: &[(u32, u32, RgbaImage)], + mode: &str, + row: Option, + fps: u32, +) -> Result, String> { + if fps == 0 || fps > 100 { + return Err("FPS must be between 1 and 100".to_string()); + } + + // Select frames based on mode + let frame_images: Vec<&RgbaImage> = match mode { + "row" => { + let target_row = row.ok_or("Row number required for row mode")?; + let mut row_tiles: Vec<_> = tiles.iter().filter(|(_, r, _)| *r == target_row).collect(); + if row_tiles.is_empty() { + return Err(format!("No tiles found in row {}", target_row)); + } + row_tiles.sort_by_key(|(c, _, _)| *c); + row_tiles.into_iter().map(|(_, _, img)| img).collect() + } + "all" => { + let mut sorted: Vec<_> = tiles.iter().collect(); + sorted.sort_by_key(|(c, r, _)| (*r, *c)); + sorted.into_iter().map(|(_, _, img)| img).collect() + } + _ => return Err(format!("Unknown GIF mode: {}", mode)), + }; + + if frame_images.is_empty() { + return Err("No frames to encode".to_string()); + } + + // GIF delay: fps → milliseconds per frame + let delay_ms = 1000u32 / fps; + let delay = Delay::from_numer_denom_ms(delay_ms, 1); + + let frames: Vec = frame_images + .into_iter() + .map(|img| Frame::from_parts(img.clone(), 0, 0, delay)) + .collect(); + + let mut buf = Cursor::new(Vec::new()); + { + let mut encoder = GifEncoder::new(&mut buf); + encoder + .set_repeat(Repeat::Infinite) + .map_err(|e| e.to_string())?; + encoder.encode_frames(frames).map_err(|e| e.to_string())?; + } + + Ok(buf.into_inner()) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +async fn open_image(path: String, state: State<'_, Mutex>) -> Result { + let img = io::load_image(std::path::Path::new(&path)).map_err(|e| e.to_string())?; + + // Run pipeline with default config + let pipeline_input = img.clone(); + let pipeline_state = tauri::async_runtime::spawn_blocking(move || { + run_pipeline(pipeline_input, &PipelineConfig::default()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + let processed = pipeline_state.image; + let (histogram, unique_colors) = build_histogram_entries(&processed, 20); + + let info = ImageInfo { + width: img.width(), + height: img.height(), + grid_size: pipeline_state.grid.map(|g| g.pitch_x), + grid_pitch_y: pipeline_state.grid.map(|g| g.pitch_y), + grid_confidence: pipeline_state.diagnostics.grid_confidence, + unique_colors, + low_confidence_blocks: pipeline_state + .diagnostics + .block_vote_shares + .as_ref() + .map(|m| m.low_confidence_blocks().len() as u32), + grid_scores: pipeline_state.diagnostics.grid_scores.clone(), + histogram, + }; + + let mut st = state.lock(); + // A new image invalidates any in-flight process run. + st.generation += 1; + st.original = Some(img); + st.processed = Some(processed); + st.config = PipelineConfig::default(); + st.diagnostics = Some(pipeline_state.diagnostics); + st.unique_colors = unique_colors; + + Ok(info) +} + +#[tauri::command] +async fn process( + pc: ProcessConfig, + state: State<'_, Mutex>, +) -> Result { + let (original, my_gen) = { + let mut st = state.lock(); + let original = st.original.clone().ok_or("No image loaded")?; + st.generation += 1; + (original, st.generation) + }; + + let config = pc.to_config(original.width(), original.height())?; + let run_config = config.clone(); + let pipeline_state = + tauri::async_runtime::spawn_blocking(move || run_pipeline(original, &run_config)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + let processed = pipeline_state.image; + let (histogram, unique_colors) = build_histogram_entries(&processed, 20); + + let result = ProcessResult { + width: processed.width(), + height: processed.height(), + grid_size: pipeline_state.grid.map(|g| g.pitch_x), + grid_pitch_y: pipeline_state.grid.map(|g| g.pitch_y), + grid_confidence: pipeline_state.diagnostics.grid_confidence, + unique_colors, + low_confidence_blocks: pipeline_state + .diagnostics + .block_vote_shares + .as_ref() + .map(|m| m.low_confidence_blocks().len() as u32), + grid_scores: pipeline_state.diagnostics.grid_scores.clone(), + histogram, + }; + + // Store results only if no newer run started while this one executed. + let mut st = state.lock(); + if st.generation == my_gen { + st.processed = Some(processed); + st.config = config; + st.diagnostics = Some(pipeline_state.diagnostics); + st.unique_colors = unique_colors; + } + + Ok(result) +} + +#[tauri::command] +async fn get_image( + which: String, + state: State<'_, Mutex>, +) -> Result { + let st = state.lock(); + let img = match which.as_str() { + "original" => st.original.as_ref().ok_or("No image loaded")?, + "processed" => st.processed.as_ref().ok_or("No processed image")?, + _ => return Err(format!("Unknown image type: {}", which)), + }; + Ok(tauri::ipc::Response::new(encode_png(img)?)) +} + +#[tauri::command] +async fn save_image(path: String, state: State<'_, Mutex>) -> Result<(), String> { + let st = state.lock(); + let img = st.processed.as_ref().ok_or("No processed image to save")?; + io::save_image(img, &PathBuf::from(&path)).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn list_palettes() -> Vec { + ALL_PALETTES + .iter() + .map(|p| PaletteInfo { + name: p.name.to_string(), + slug: p.slug.to_string(), + num_colors: p.colors.len(), + }) + .collect() +} + +#[tauri::command] +async fn fetch_lospec(slug: String) -> Result { + let palette = lospec::fetch_lospec_palette(&slug, false).map_err(|e| e.to_string())?; + Ok(LospecResult { + name: palette.name, + slug: palette.slug, + num_colors: palette.colors.len(), + colors: palette + .colors + .iter() + .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) + .collect(), + }) +} + +#[tauri::command] +fn get_palette_colors(slug: String) -> Result, String> { + let pal = ALL_PALETTES + .iter() + .find(|p| p.slug == slug) + .ok_or_else(|| format!("Unknown palette: {}", slug))?; + Ok(pal + .colors + .iter() + .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) + .collect()) +} + +#[tauri::command] +fn load_palette_file(path: String) -> Result, String> { + let colors = palettes::load_hex_file(std::path::Path::new(&path)).map_err(|e| e.to_string())?; + if colors.is_empty() { + return Err("No valid hex colors found in file".to_string()); + } + Ok(colors + .iter() + .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) + .collect()) +} + +// --------------------------------------------------------------------------- +// Batch processing +// --------------------------------------------------------------------------- + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct BatchProgress { + current: u32, + total: u32, + filename: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BatchSummary { + succeeded: u32, + failed: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BatchFailure { + path: String, + error: String, +} + +#[tauri::command] +async fn batch_process( + input_paths: Vec, + output_dir: String, + pc: ProcessConfig, + overwrite: bool, + app: tauri::AppHandle, +) -> Result { + if input_paths.is_empty() { + return Err("No input files".to_string()); + } + // A scale multiplier depends on each image's own dimensions, but batch + // shares one config across all files. + if pc.output_scale.is_some_and(|s| s > 1) + && pc.output_width.is_none() + && pc.output_height.is_none() + { + return Err( + "Batch can't apply a scale multiplier; set an explicit output width and height instead" + .to_string(), + ); + } + + let out_dir = PathBuf::from(&output_dir); + std::fs::create_dir_all(&out_dir) + .map_err(|e| format!("Failed to create output directory: {}", e))?; + + // Scale was rejected above, so the dimensions passed here are never used. + let config = pc.to_config(0, 0)?; + + let inputs = ResolvedInputs { + // Only used with preserve_dirs, which batch in pixfix doesn't set. + base: PathBuf::new(), + files: input_paths.iter().map(PathBuf::from).collect(), + }; + let opts = BatchOptions { + overwrite, + ..BatchOptions::default() + }; + + let total = input_paths.len() as u32; + let result = tauri::async_runtime::spawn_blocking(move || { + let done = AtomicU32::new(0); + run_batch(&inputs, &out_dir, &config, &opts, |event| { + let filename = match &event { + BatchEvent::FileDone { input, .. } + | BatchEvent::FileSkipped { input, .. } + | BatchEvent::FileFailed { input, .. } => { + input.file_name().map(|n| n.to_string_lossy().into_owned()) + } + _ => None, + }; + if let Some(filename) = filename { + let current = done.fetch_add(1, Ordering::Relaxed) + 1; + let _ = app.emit( + "batch-progress", + BatchProgress { + current, + total, + filename, + }, + ); + } + }) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + let failed = result + .files + .iter() + .filter(|f| f.status != FileStatus::Ok) + .map(|f| BatchFailure { + path: f.input.display().to_string(), + error: f + .error + .clone() + .unwrap_or_else(|| "unknown error".to_string()), + }) + .collect(); + + Ok(BatchSummary { + succeeded: result.succeeded() as u32, + failed, + }) +} + +// --------------------------------------------------------------------------- +// Sprite sheet processing +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SheetPreviewResult { + tile_count: u32, + tile_width: u32, + tile_height: u32, + cols: u32, + rows: u32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SheetProcessResult { + tile_count: u32, + tile_width: u32, + tile_height: u32, + cols: u32, + rows: u32, + output_width: u32, + output_height: u32, +} + +fn auto_split_config( + separator_threshold: Option, + min_sprite_size: Option, + pad: Option, +) -> pixfix::spritesheet::AutoSplitConfig { + pixfix::spritesheet::AutoSplitConfig { + // None: let the library detect transparency or the border color + // instead of assuming a white background. + bg_color: None, + tolerance: 0.10, + separator_threshold: separator_threshold.unwrap_or(0.90), + min_sprite_size: min_sprite_size.unwrap_or(8), + pad: pad.unwrap_or(0), + } +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn sheet_preview( + mode: String, + tile_width: Option, + tile_height: Option, + spacing: Option, + margin: Option, + separator_threshold: Option, + min_sprite_size: Option, + pad: Option, + state: State<'_, Mutex>, +) -> Result { + let original = state.lock().original.clone().ok_or("No image loaded")?; + + tauri::async_runtime::spawn_blocking(move || { + use pixfix::spritesheet; + + match mode.as_str() { + "fixed" => { + let tw = tile_width.ok_or("tile_width required for fixed mode")?; + let th = tile_height.ok_or("tile_height required for fixed mode")?; + let sp = spacing.unwrap_or(0); + let mg = margin.unwrap_or(0); + let tiles = spritesheet::split_sheet(&original, tw, th, sp, mg); + let cols = if tiles.is_empty() { + 0 + } else { + tiles.iter().map(|t| t.col).max().unwrap() + 1 + }; + let rows = if tiles.is_empty() { + 0 + } else { + tiles.iter().map(|t| t.row).max().unwrap() + 1 + }; + Ok(SheetPreviewResult { + tile_count: tiles.len() as u32, + tile_width: tw, + tile_height: th, + cols, + rows, + }) + } + "auto" => { + let auto_config = auto_split_config(separator_threshold, min_sprite_size, pad); + let (tiles, tw, th) = spritesheet::auto_split_sheet(&original, &auto_config) + .map_err(|e| e.to_string())?; + let cols = if tiles.is_empty() { + 0 + } else { + tiles.iter().map(|t| t.col).max().unwrap() + 1 + }; + let rows = if tiles.is_empty() { + 0 + } else { + tiles.iter().map(|t| t.row).max().unwrap() + 1 + }; + Ok(SheetPreviewResult { + tile_count: tiles.len() as u32, + tile_width: tw, + tile_height: th, + cols, + rows, + }) + } + _ => Err(format!("Unknown sheet mode: {}", mode)), + } + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn sheet_process( + mode: String, + tile_width: Option, + tile_height: Option, + spacing: Option, + margin: Option, + separator_threshold: Option, + min_sprite_size: Option, + pad: Option, + no_normalize: Option, + pc: ProcessConfig, + state: State<'_, Mutex>, +) -> Result { + let original = state.lock().original.clone().ok_or("No image loaded")?; + + let skip_pipeline = no_normalize.unwrap_or(false); + let config = pc.to_config(original.width(), original.height())?; + + let (result, tiles, actual_tw, actual_th) = tauri::async_runtime::spawn_blocking(move || { + use pixfix::spritesheet; + + match mode.as_str() { + "fixed" => { + let tw = tile_width.ok_or("tile_width required for fixed mode")?; + let th = tile_height.ok_or("tile_height required for fixed mode")?; + let sp = spacing.unwrap_or(0); + let mg = margin.unwrap_or(0); + + if skip_pipeline { + let tiles = spritesheet::split_sheet(&original, tw, th, sp, mg); + let sheet = spritesheet::assemble_sheet(&tiles, tw, th, sp, mg); + Ok((sheet, tiles, tw, th)) + } else { + spritesheet::process_sheet(&original, tw, th, sp, mg, &config) + .map_err(|e| e.to_string()) + } + } + "auto" => { + let auto_config = auto_split_config(separator_threshold, min_sprite_size, pad); + let pipeline_ref = if skip_pipeline { None } else { Some(&config) }; + spritesheet::process_sheet_auto(&original, &auto_config, pipeline_ref) + .map_err(|e| e.to_string()) + } + _ => Err(format!("Unknown sheet mode: {}", mode)), + } + }) + .await + .map_err(|e| e.to_string())??; + + let out_w = result.width(); + let out_h = result.height(); + let cols = if tiles.is_empty() { + 0 + } else { + tiles.iter().map(|t| t.col).max().unwrap() + 1 + }; + let rows = if tiles.is_empty() { + 0 + } else { + tiles.iter().map(|t| t.row).max().unwrap() + 1 + }; + let tile_count = tiles.len() as u32; + + let sheet_tiles: Vec<(u32, u32, RgbaImage)> = + tiles.into_iter().map(|t| (t.col, t.row, t.image)).collect(); + + let mut st = state.lock(); + // Sheet results supersede any in-flight single-image process run. + st.generation += 1; + st.processed = Some(result); + st.sheet_tiles = Some(sheet_tiles); + + Ok(SheetProcessResult { + tile_count, + tile_width: actual_tw, + tile_height: actual_th, + cols, + rows, + output_width: out_w, + output_height: out_h, + }) +} + +#[tauri::command] +async fn sheet_save_tiles( + output_dir: String, + state: State<'_, Mutex>, +) -> Result { + let st = state.lock(); + let tiles = st.sheet_tiles.as_ref().ok_or("No sheet tiles available")?; + + let out_dir = PathBuf::from(&output_dir); + std::fs::create_dir_all(&out_dir) + .map_err(|e| format!("Failed to create output directory: {}", e))?; + + let mut count = 0u32; + for (col, row, img) in tiles { + let path = out_dir.join(format!("tile_{}_{}.png", row, col)); + io::save_image(img, &path).map_err(|e| format!("Failed to save tile: {}", e))?; + count += 1; + } + Ok(count) +} + +#[tauri::command] +async fn sheet_generate_gif( + mode: String, + row: Option, + fps: u32, + state: State<'_, Mutex>, +) -> Result { + let st = state.lock(); + let tiles = st + .sheet_tiles + .as_ref() + .ok_or("No sheet tiles available. Process a sheet first.")?; + + let gif_bytes = build_gif_bytes(tiles, &mode, row, fps)?; + Ok(tauri::ipc::Response::new(gif_bytes)) +} + +#[tauri::command] +async fn sheet_export_gif( + path: String, + mode: String, + row: Option, + fps: u32, + state: State<'_, Mutex>, +) -> Result<(), String> { + let st = state.lock(); + let tiles = st + .sheet_tiles + .as_ref() + .ok_or("No sheet tiles available. Process a sheet first.")?; + + let gif_bytes = build_gif_bytes(tiles, &mode, row, fps)?; + std::fs::write(&path, &gif_bytes).map_err(|e| format!("Failed to write GIF: {}", e))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// App setup +// --------------------------------------------------------------------------- + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(Mutex::new(AppState::default())) + .invoke_handler(tauri::generate_handler![ + open_image, + process, + get_image, + save_image, + list_palettes, + fetch_lospec, + get_palette_colors, + load_palette_file, + batch_process, + sheet_preview, + sheet_process, + sheet_save_tiles, + sheet_generate_gif, + sheet_export_gif, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/pixfix/src/main.rs b/desktop/src/main.rs similarity index 75% rename from pixfix/src/main.rs rename to desktop/src/main.rs index 9a82768..2c2fc3d 100644 --- a/pixfix/src/main.rs +++ b/desktop/src/main.rs @@ -1,5 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - pixfix::run() + pixfix_desktop::run() } diff --git a/pixfix/tauri.conf.json b/desktop/tauri.conf.json similarity index 64% rename from pixfix/tauri.conf.json rename to desktop/tauri.conf.json index e44dcf7..8948df5 100644 --- a/pixfix/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -5,8 +5,8 @@ "identifier": "com.pixfix.app", "build": { "frontendDist": "./ui", - "beforeDevCommand": "bun build pixfix/ui/src/app.ts --outfile pixfix/ui/app.js --sourcemap=inline", - "beforeBuildCommand": "bun build pixfix/ui/src/app.ts --outfile pixfix/ui/app.js --minify" + "beforeDevCommand": "bun build desktop/ui/src/app.ts --outfile desktop/ui/app.js --sourcemap=inline", + "beforeBuildCommand": "bun build desktop/ui/src/app.ts --outfile desktop/ui/app.js --minify" }, "app": { "withGlobalTauri": true, @@ -22,7 +22,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' blob: data: asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'" + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' blob: data: asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'" } }, "bundle": { diff --git a/pixfix/ui/index.html b/desktop/ui/index.html similarity index 100% rename from pixfix/ui/index.html rename to desktop/ui/index.html diff --git a/pixfix/ui/src/app.ts b/desktop/ui/src/app.ts similarity index 90% rename from pixfix/ui/src/app.ts rename to desktop/ui/src/app.ts index 23bebe4..81c8f5e 100644 --- a/pixfix/ui/src/app.ts +++ b/desktop/ui/src/app.ts @@ -44,8 +44,10 @@ interface ImageInfo { width: number; height: number; gridSize: number | null; + gridPitchY: number | null; gridConfidence: number | null; uniqueColors: number; + lowConfidenceBlocks: number | null; gridScores: [number, number][]; histogram: ColorEntry[]; } @@ -54,8 +56,10 @@ interface ProcessResult { width: number; height: number; gridSize: number | null; + gridPitchY: number | null; gridConfidence: number | null; uniqueColors: number; + lowConfidenceBlocks: number | null; gridScores: [number, number][]; histogram: ColorEntry[]; } @@ -86,6 +90,8 @@ interface ProcessConfig { gridPhaseX: number | null; gridPhaseY: number | null; maxGridCandidate: number | null; + coarsen: number | null; + minConfidence: number | null; noGridDetect: boolean; downscaleMode: string; aaThreshold: number | null; @@ -98,6 +104,7 @@ interface ProcessConfig { borderThreshold: number | null; bgTolerance: number; floodFill: boolean; + chromaKey: string | null; outputScale: number | null; outputWidth: number | null; outputHeight: number | null; @@ -112,6 +119,8 @@ interface AppConfig { gridPhaseX: number | null; gridPhaseY: number | null; maxGridCandidate: number; + coarsen: number | null; + minConfidence: number | null; noGridDetect: boolean; downscaleMode: string; aaThreshold: number | null; @@ -125,6 +134,7 @@ interface AppConfig { borderThreshold: number | null; bgTolerance: number; floodFill: boolean; + chromaKey: string | null; outputScale: number | null; outputWidth: number | null; outputHeight: number | null; @@ -136,6 +146,7 @@ interface AppState { imagePath: string | null; imageInfo: ImageInfo | null; settingsFocusIndex: number; + showAdvanced: boolean; processing: boolean; palettes: PaletteInfo[]; paletteIndex: number; @@ -184,6 +195,7 @@ const state: AppState = { imagePath: null, imageInfo: null, settingsFocusIndex: 0, + showAdvanced: false, processing: false, palettes: [], paletteIndex: 0, @@ -192,6 +204,8 @@ const state: AppState = { gridPhaseX: null, gridPhaseY: null, maxGridCandidate: 32, + coarsen: null, + minConfidence: null, noGridDetect: false, downscaleMode: 'snap', aaThreshold: null, @@ -202,6 +216,7 @@ const state: AppState = { noQuantize: false, removeBg: false, bgColor: null, + chromaKey: null, borderThreshold: null, bgTolerance: 0.05, floodFill: true, @@ -254,11 +269,13 @@ const DOWNSCALE_MODES = ['snap', 'center-weighted', 'majority-vote', 'center-pix interface SettingSection { section: string; key?: undefined; + advanced?: undefined; } interface SettingRow { section?: undefined; key: string; + advanced?: boolean; label: string; value: string; help: string; @@ -267,7 +284,7 @@ interface SettingRow { type SettingEntry = SettingSection | SettingRow; -function getSettings(): SettingEntry[] { +function getAllSettings(): SettingEntry[] { const c = state.config; return [ { section: 'Grid Detection' }, @@ -278,25 +295,37 @@ function getSettings(): SettingEntry[] { changed: c.gridSize !== null, }, { - key: 'gridPhaseX', label: 'Phase X', + advanced: true, key: 'gridPhaseX', label: 'Phase X', value: c.gridPhaseX === null ? 'auto' : String(c.gridPhaseX), help: 'Override the X offset of the grid alignment. Usually auto-detected.', changed: c.gridPhaseX !== null, }, { - key: 'gridPhaseY', label: 'Phase Y', + advanced: true, key: 'gridPhaseY', label: 'Phase Y', value: c.gridPhaseY === null ? 'auto' : String(c.gridPhaseY), help: 'Override the Y offset of the grid alignment. Usually auto-detected.', changed: c.gridPhaseY !== null, }, { - key: 'noGridDetect', label: 'Skip Grid', + advanced: true, key: 'noGridDetect', label: 'Skip Grid', value: c.noGridDetect ? 'on' : 'off', help: 'Skip grid detection entirely. Useful if your image is already at logical resolution.', changed: c.noGridDetect, }, { - key: 'maxGridCandidate', label: 'Max Grid', + key: 'coarsen', label: 'Coarsen', + value: c.coarsen === null ? 'off' : c.coarsen + 'x', + help: 'Multiply the final grid pitch by an integer. Detection finds the grid the generator rendered on; the intended pixel-art resolution is often 2x or more coarser. Phase is preserved.', + changed: c.coarsen !== null, + }, + { + advanced: true, key: 'minConfidence', label: 'Min Confidence', + value: c.minConfidence === null ? '0.35' : c.minConfidence.toFixed(2), + help: 'Confidence floor below which grid detection declines to snap (0.0–1.0, default 0.35). Lower it to accept shakier grids on heavily anti-aliased images.', + changed: c.minConfidence !== null, + }, + { + advanced: true, key: 'maxGridCandidate', label: 'Max Grid', value: String(c.maxGridCandidate), help: 'Maximum grid size to test during auto-detection (default: 32).', changed: c.maxGridCandidate !== 32, @@ -359,23 +388,29 @@ function getSettings(): SettingEntry[] { changed: c.bgColor !== null, }, { - key: 'borderThreshold', label: 'Border Thresh', + advanced: true, key: 'borderThreshold', label: 'Border Thresh', value: c.borderThreshold === null ? '0.40' : c.borderThreshold.toFixed(2), help: 'Fraction of border pixels that must match for auto-detection (0.0\u20131.0, default: 0.40).', changed: c.borderThreshold !== null, }, { - key: 'bgTolerance', label: 'BG Tolerance', + advanced: true, key: 'bgTolerance', label: 'BG Tolerance', value: c.bgTolerance.toFixed(2), help: 'How different a pixel can be from the background color and still count as background. Higher = more aggressive.', changed: c.bgTolerance !== 0.05, }, { - key: 'floodFill', label: 'Flood Fill', + advanced: true, key: 'floodFill', label: 'Flood Fill', value: c.floodFill ? 'on' : 'off', help: 'On: only remove connected background from edges. Off: remove matching color everywhere.', changed: !c.floodFill, }, + { + key: 'chromaKey', label: 'Chroma Key', + value: c.chromaKey === null ? 'off' : c.chromaKey, + help: 'Green-screen style removal: these colors become transparent everywhere in the image, independent of background detection. Comma-separate multiple keys (#FF00FF, #00FF00).', + changed: c.chromaKey !== null, + }, { section: 'Output' }, { key: 'outputScale', label: 'Scale', @@ -384,13 +419,13 @@ function getSettings(): SettingEntry[] { changed: c.outputScale !== null, }, { - key: 'outputWidth', label: 'Width', + advanced: true, key: 'outputWidth', label: 'Width', value: c.outputWidth === null ? 'auto' : String(c.outputWidth), help: 'Explicit output width in pixels. Overrides scale.', changed: c.outputWidth !== null, }, { - key: 'outputHeight', label: 'Height', + advanced: true, key: 'outputHeight', label: 'Height', value: c.outputHeight === null ? 'auto' : String(c.outputHeight), help: 'Explicit output height in pixels. Overrides scale.', changed: c.outputHeight !== null, @@ -398,6 +433,31 @@ function getSettings(): SettingEntry[] { ]; } +// Curated view: auto-detected tuning settings collapse into a bottom +// "Advanced" section (mirrors the CLI's short -h vs full --help split). +function getSettings(): SettingEntry[] { + const entries = getAllSettings(); + const advanced = entries.filter((s): s is SettingRow => !s.section && !!s.advanced); + const tuned = advanced.filter((s) => s.changed).length; + const out: SettingEntry[] = entries.filter((s) => s.section || !s.advanced); + out.push({ section: 'Advanced' }); + out.push({ + key: 'showAdvanced', label: 'Tuning', + value: state.showAdvanced ? 'hide' : tuned > 0 ? `show (${tuned} tuned)` : 'show', + help: 'Grid phase, confidence floor, detection limits, background thresholds, and exact output size. Auto-detection handles these for most images.', + changed: !state.showAdvanced && tuned > 0, + }); + if (state.showAdvanced) out.push(...advanced); + return out; +} + +function toggleAdvanced(): void { + state.showAdvanced = !state.showAdvanced; + const idx = getSettingRows().findIndex((r) => r.key === 'showAdvanced'); + if (idx >= 0) state.settingsFocusIndex = idx; + renderSettings(); +} + function getSettingRows(): SettingRow[] { return getSettings().filter((s): s is SettingRow => !s.section); } @@ -633,7 +693,7 @@ const SELECT_SETTINGS = ['downscaleMode', 'paletteName']; // Settings that are boolean toggles const BOOLEAN_SETTINGS = ['removeBg', 'floodFill', 'noGridDetect', 'noQuantize']; // Settings that require Enter-to-edit (text/numeric input) -const INPUT_SETTINGS = ['gridSize', 'gridPhaseX', 'gridPhaseY', 'maxGridCandidate', 'aaThreshold', 'autoColors', 'bgColor', 'borderThreshold', 'bgTolerance', 'lospecSlug', 'outputScale', 'outputWidth', 'outputHeight']; +const INPUT_SETTINGS = ['gridSize', 'gridPhaseX', 'gridPhaseY', 'maxGridCandidate', 'coarsen', 'minConfidence', 'aaThreshold', 'autoColors', 'bgColor', 'borderThreshold', 'bgTolerance', 'chromaKey', 'lospecSlug', 'outputScale', 'outputWidth', 'outputHeight']; // Settings that open a file dialog instead of editing const FILE_SETTINGS = ['paletteFile']; // Nullable settings — can be turned off (null) with a clear button @@ -678,7 +738,9 @@ function renderSettings(): void { html += `${s.label}`; html += ``; - if (SELECT_SETTINGS.includes(s.key)) { + if (s.key === 'showAdvanced') { + html += `${escapeHtml(s.value)}`; + } else if (SELECT_SETTINGS.includes(s.key)) { // Always render as dropdown html += renderInlineSelect(s.key); } else if (BOOLEAN_SETTINGS.includes(s.key)) { @@ -791,6 +853,14 @@ function renderInlineInput(key: string): string { const val = c.gridPhaseY === null ? '' : c.gridPhaseY; return ``; } + case 'coarsen': { + const val = c.coarsen === null ? '' : c.coarsen; + return ``; + } + case 'minConfidence': { + const val = c.minConfidence === null ? '' : c.minConfidence.toFixed(2); + return ``; + } case 'maxGridCandidate': { return ``; } @@ -806,6 +876,10 @@ function renderInlineInput(key: string): string { const val = c.bgColor ?? ''; return ``; } + case 'chromaKey': { + const val = c.chromaKey ?? ''; + return ``; + } case 'borderThreshold': { const val = c.borderThreshold === null ? '' : c.borderThreshold.toFixed(2); return ``; @@ -836,6 +910,10 @@ function renderInlineInput(key: string): string { } function startEditing(key: string): void { + if (key === 'showAdvanced') { + toggleAdvanced(); + return; + } // Booleans toggle immediately if (BOOLEAN_SETTINGS.includes(key)) { adjustSetting(key, 1); @@ -886,6 +964,9 @@ function clearSetting(key: string): void { } break; case 'bgColor': c.bgColor = null; break; + case 'chromaKey': c.chromaKey = null; break; + case 'coarsen': c.coarsen = null; break; + case 'minConfidence': c.minConfidence = null; break; case 'borderThreshold': c.borderThreshold = null; break; case 'outputScale': c.outputScale = null; break; case 'outputWidth': c.outputWidth = null; break; @@ -924,6 +1005,14 @@ function commitEdit(key: string, rawValue: string): void { if (!isNaN(n) && n >= 0) c.gridPhaseY = n; } break; + case 'coarsen': { + const val = c.coarsen === null ? '' : c.coarsen; + return ``; + } + case 'minConfidence': { + const val = c.minConfidence === null ? '' : c.minConfidence.toFixed(2); + return ``; + } case 'maxGridCandidate': { const n = parseInt(val); if (!isNaN(n) && n >= 2) c.maxGridCandidate = Math.min(64, n); @@ -963,6 +1052,32 @@ function commitEdit(key: string, rawValue: string): void { } } break; + case 'chromaKey': + if (val === '' || val === 'off') { + c.chromaKey = null; + } else { + const chromaHex = val.startsWith('#') ? val : '#' + val; + if (/^#[0-9A-Fa-f]{6}$/.test(chromaHex)) { + c.chromaKey = chromaHex.toUpperCase(); + } + } + break; + case 'coarsen': + if (val === '' || val === 'off') { + c.coarsen = null; + } else { + const n = parseInt(val, 10); + if (!isNaN(n) && n >= 1) c.coarsen = Math.min(16, n); + } + break; + case 'minConfidence': + if (val === '' || val === 'auto') { + c.minConfidence = null; + } else { + const n = parseFloat(val); + if (!isNaN(n)) c.minConfidence = Math.max(0.0, Math.min(1.0, n)); + } + break; case 'borderThreshold': if (val === '' || val === 'auto') { c.borderThreshold = null; @@ -1038,6 +1153,12 @@ function commitEdit(key: string, rawValue: string): void { // Diagnostics rendering // --------------------------------------------------------------------------- +// Grid pitches can be fractional (e.g. 10.667 for a 48-cell sprite at 512px) +function fmtGrid(v: number | null | undefined): string { + if (v == null) return 'none'; + return Number.isInteger(v) ? String(v) : v.toFixed(3); +} + function renderDiagnostics(): void { const info = state.imageInfo; if (!info) { @@ -1049,9 +1170,16 @@ function renderDiagnostics(): void { return; } + let sizeStr = fmtGrid(info.gridSize); + if (info.gridSize != null && info.gridPitchY != null && info.gridPitchY !== info.gridSize) { + sizeStr = `${fmtGrid(info.gridSize)} × ${fmtGrid(info.gridPitchY)}`; + } let gridHtml = ''; - gridHtml += `
Detected size${info.gridSize ?? 'none'}
`; + gridHtml += `
Detected size${sizeStr}
`; gridHtml += `
Confidence${info.gridConfidence != null ? (info.gridConfidence * 100).toFixed(1) + '%' : 'n/a'}
`; + if (info.lowConfidenceBlocks != null) { + gridHtml += `
Low-confidence blocks${info.lowConfidenceBlocks}
`; + } document.getElementById('diag-grid-info')!.innerHTML = gridHtml; let barsHtml = ''; @@ -1093,10 +1221,19 @@ function renderDiagnostics(): void { // Image loading and processing // --------------------------------------------------------------------------- +// Raw IPC responses resolve to an ArrayBuffer over the custom-protocol +// transport, but the postMessage fallback (used when the custom protocol is +// unavailable) delivers the same bytes as a plain JSON number array. Blob +// would silently stringify an array, so normalize before constructing one. +function ipcBytes(buf: ArrayBuffer | Uint8Array | number[]): Uint8Array { + if (buf instanceof ArrayBuffer) return new Uint8Array(buf); + if (buf instanceof Uint8Array) return buf; + return new Uint8Array(buf); +} + async function loadImageBlob(which: string): Promise { - const bytes = await invoke('get_image', { which }); - const arr = new Uint8Array(bytes); - const blob = new Blob([arr], { type: 'image/png' }); + const buf = await invoke('get_image', { which }); + const blob = new Blob([ipcBytes(buf)], { type: 'image/png' }); return URL.createObjectURL(blob); } @@ -1140,7 +1277,7 @@ async function openImage(path: string): Promise { renderSettings(); renderDiagnostics(); - setStatus(`Loaded \u2014 ${info.width}\u00d7${info.height}, grid=${info.gridSize ?? 'none'}, ${info.uniqueColors} colors`, 'success'); + setStatus(`Loaded \u2014 ${info.width}\u00d7${info.height}, grid=${fmtGrid(info.gridSize)}, ${info.uniqueColors} colors`, 'success'); } catch (e) { hideWelcomeLoading(); setStatus('Error: ' + e, 'error'); @@ -1154,6 +1291,8 @@ function buildProcessConfig(): ProcessConfig { gridPhaseX: c.gridPhaseX, gridPhaseY: c.gridPhaseY, maxGridCandidate: c.maxGridCandidate === 32 ? null : c.maxGridCandidate, + coarsen: c.coarsen, + minConfidence: c.minConfidence, noGridDetect: c.noGridDetect, downscaleMode: c.downscaleMode, aaThreshold: c.aaThreshold, @@ -1166,6 +1305,7 @@ function buildProcessConfig(): ProcessConfig { borderThreshold: c.borderThreshold, bgTolerance: c.bgTolerance, floodFill: c.floodFill, + chromaKey: c.chromaKey, outputScale: c.outputScale, outputWidth: c.outputWidth, outputHeight: c.outputHeight, @@ -1320,6 +1460,10 @@ document.addEventListener('keydown', (e: KeyboardEvent) => { e.preventDefault(); const row = rows[state.settingsFocusIndex]; if (row) { + if (row.key === 'showAdvanced') { + toggleAdvanced(); + return; + } adjustSetting(row.key, 1); renderSettings(); autoProcess(); @@ -1330,6 +1474,10 @@ document.addEventListener('keydown', (e: KeyboardEvent) => { e.preventDefault(); const row = rows[state.settingsFocusIndex]; if (row) { + if (row.key === 'showAdvanced') { + toggleAdvanced(); + return; + } adjustSetting(row.key, -1); renderSettings(); autoProcess(); @@ -1774,16 +1922,18 @@ async function gifPreviewAction(): Promise { if (state.gifGenerating) return; readGifConfig(); state.gifGenerating = true; + if (state.gifPreviewUrl) URL.revokeObjectURL(state.gifPreviewUrl); state.gifPreviewUrl = null; renderSheet(); setStatus('Generating GIF preview...', 'processing'); try { - const dataUrl = await invoke('sheet_generate_gif', { + // sheet_generate_gif returns raw IPC bytes + const buf = await invoke('sheet_generate_gif', { mode: state.gifMode, row: state.gifMode === 'row' ? state.gifRow : null, fps: state.gifFps, }); - state.gifPreviewUrl = dataUrl; + state.gifPreviewUrl = URL.createObjectURL(new Blob([ipcBytes(buf)], { type: 'image/gif' })); setStatus('GIF preview generated', 'success'); } catch (e) { setStatus('GIF error: ' + e, 'error'); diff --git a/pixfix/ui/style.css b/desktop/ui/style.css similarity index 100% rename from pixfix/ui/style.css rename to desktop/ui/style.css diff --git a/docs/demo/pkg/package.json b/docs/demo/pkg/package.json new file mode 100644 index 0000000..a8005ff --- /dev/null +++ b/docs/demo/pkg/package.json @@ -0,0 +1,16 @@ +{ + "name": "pixfix-wasm", + "description": "Browser bindings for the pixfix pipeline (docs-site demo)", + "version": "0.1.0", + "license": "MIT", + "files": [ + "pixfix_wasm_bg.wasm", + "pixfix_wasm.js", + "pixfix_wasm.d.ts" + ], + "module": "pixfix_wasm.js", + "types": "pixfix_wasm.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} \ No newline at end of file diff --git a/docs/demo/pkg/pixfix_wasm.d.ts b/docs/demo/pkg/pixfix_wasm.d.ts new file mode 100644 index 0000000..145bd57 --- /dev/null +++ b/docs/demo/pkg/pixfix_wasm.d.ts @@ -0,0 +1,48 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Normalize an image (PNG/JPEG bytes) with the default snap pipeline. + * + * - `colors`: quantization palette size; 0 skips quantization, otherwise + * clamped to at least 2. + * - `coarsen`: integer pitch multiplier (1 = off), same as `--coarsen`. + * + * Returns `{ png: Uint8Array, pitchX, pitchY, confidence, logicalW, + * logicalH, accepted, guessPitch }` where the grid fields are null when + * detection declined to snap (`accepted: false`; `guessPitch` then carries + * the best guess, if any). + */ +export function normalize(png_bytes: Uint8Array, colors: number, coarsen: number): any; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly normalize: (a: number, b: number, c: number, d: number, e: number) => void; + readonly __wbindgen_export: (a: number) => void; + readonly __wbindgen_add_to_stack_pointer: (a: number) => number; + readonly __wbindgen_export2: (a: number, b: number) => number; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/docs/demo/pkg/pixfix_wasm.js b/docs/demo/pkg/pixfix_wasm.js new file mode 100644 index 0000000..c8099a2 --- /dev/null +++ b/docs/demo/pkg/pixfix_wasm.js @@ -0,0 +1,250 @@ +/* @ts-self-types="./pixfix_wasm.d.ts" */ + +/** + * Normalize an image (PNG/JPEG bytes) with the default snap pipeline. + * + * - `colors`: quantization palette size; 0 skips quantization, otherwise + * clamped to at least 2. + * - `coarsen`: integer pitch multiplier (1 = off), same as `--coarsen`. + * + * Returns `{ png: Uint8Array, pitchX, pitchY, confidence, logicalW, + * logicalH, accepted, guessPitch }` where the grid fields are null when + * detection declined to snap (`accepted: false`; `guessPitch` then carries + * the best guess, if any). + * @param {Uint8Array} png_bytes + * @param {number} colors + * @param {number} coarsen + * @returns {any} + */ +export function normalize(png_bytes, colors, coarsen) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(png_bytes, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.normalize(retptr, ptr0, len0, colors, coarsen); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + return takeObject(r0); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_new_ab79df5bd7c26067: function() { + const ret = new Object(); + return addHeapObject(ret); + }, + __wbg_new_from_slice_22da9388ac046e50: function(arg0, arg1) { + const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, + __wbg_set_7eaa4f96924fd6b3: function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); + return ret; + }, arguments); }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }, + __wbindgen_object_drop_ref: function(arg0) { + takeObject(arg0); + }, + }; + return { + __proto__: null, + "./pixfix_wasm_bg.js": import0, + }; +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +function dropObject(idx) { + if (idx < 1028) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function getObject(idx) { return heap[idx]; } + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_export(addHeapObject(e)); + } +} + +let heap = new Array(1024).fill(undefined); +heap.push(undefined, null, true, false); + +let heap_next = heap.length; + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('pixfix_wasm_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/docs/demo/pkg/pixfix_wasm_bg.wasm b/docs/demo/pkg/pixfix_wasm_bg.wasm new file mode 100644 index 0000000..30fbf45 Binary files /dev/null and b/docs/demo/pkg/pixfix_wasm_bg.wasm differ diff --git a/docs/demo/pkg/pixfix_wasm_bg.wasm.d.ts b/docs/demo/pkg/pixfix_wasm_bg.wasm.d.ts new file mode 100644 index 0000000..662933d --- /dev/null +++ b/docs/demo/pkg/pixfix_wasm_bg.wasm.d.ts @@ -0,0 +1,7 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const normalize: (a: number, b: number, c: number, d: number, e: number) => void; +export const __wbindgen_export: (a: number) => void; +export const __wbindgen_add_to_stack_pointer: (a: number) => number; +export const __wbindgen_export2: (a: number, b: number) => number; diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..b47a7b5 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,481 @@ + + + + + + pixfix — clean pixel art, one grid at a time + + + + + + + + + +
+ +

pixfix

+

+ AI image generators make beautiful pixel art that isn't actually pixel art — + the "pixels" are misaligned, noisy, and rendered at fractional sizes like + 10.667px. This tool detects the real grid and rebuilds the image into a + clean, game-ready asset. +

+
+ $ cargo install pixfix + $ pixfix process sprite.png +
+
+ +
+ +
+

## The pipeline

+

+ Every image runs through the same stages. Each one is skippable and + configurable, and everything the pipeline learns is reported back to you. +

+
    +
  1. +

    Grid detection

    +

    The image is reduced to gradient profiles and scanned with a fractional + comb — pitch doesn't have to be an integer, so a 48-cell sprite rendered + at 512px detects as exactly 10.667px per cell. When confidence is too low + to trust, the pipeline says so and leaves your image unsnapped instead of + inventing a grid.

    +
  2. +
  3. +

    Anti-aliasing removal opt-in

    +

    Pixels that sit on the interpolation line between their two dominant + neighbors snap to the closer one, over multiple passes for wide AA ramps. + A guard protects checkerboard dithering from being eaten.

    +
  4. +
  5. +

    Background & chroma masking

    +

    Border colors are clustered in OKLAB (gradients and vignettes work), + masks are built at full resolution where fringes are one pixel wide, and + chroma keys remove their color everywhere. Nothing is cleared yet — the + mask rides along so the next stage can't bake halos into blocks.

    +
  6. +
  7. +

    Grid snapping

    +

    Each block votes on its color with near-duplicates pooled in OKLAB, and + the winner paints its most representative actual color — never an + invented average. Alpha binarizes per block for clean game-asset edges. + Blocks with contested votes are flagged so you can spot a bad grid fast.

    +
  8. +
  9. +

    Dither detection

    +

    Checkerboard and row/column alternation between color pairs is detected + on the logical grid. Dithering is how pixel art fakes gradients — it looks + better kept, so detected pairs get pinned through quantization.

    +
  10. +
  11. +

    Quantization

    +

    Seeded weighted k-means over unique colors — deterministic, exact, and + rare structural colors like 1px outlines can't be dropped. Palettes come + from extraction, built-ins, .hex files, or Lospec; matching uses hyAB so + dark blues become navy, not black.

    +
  12. +
  13. +

    Output

    +

    Snap mode keeps the original resolution. Reduced modes emit the true + logical size or a crisp integer re-upscale — never a stretch back to + dimensions that recreate the misalignment you just removed.

    +
  14. +
+
+ +
+

## Quick start

+
+

Clean up an AI sprite (grid auto-detected):

+
$ pixfix process sprite.png
+Grid: 10.667x10.667 pixels, phase: (0, 0), confidence: 85%, logical: 48x48
+Saved: sprite_normalized.png
+
+
+

Inspect before you commit to anything:

+
$ pixfix analyze sprite.png --json | jq .grid
+
+
+

Reduce to true resolution with a 16-color palette and transparent background:

+
$ pixfix process sprite.png out.png \
+    --downscale-mode majority-vote --logical-size --colors 16 --remove-bg
+
+
+

Key out a green screen (works on interior regions too):

+
$ pixfix process sprite.png out.png --chroma-key 00FF00
+
+
+

A whole directory in parallel, PICO-8 palette:

+
$ pixfix batch ./sprites out/ --palette pico-8
+
+
+ +
+

## Try it

+

+ The same pipeline, compiled to WebAssembly. Everything runs right here in + the page — your image is never uploaded anywhere. +

+
+
+ drop a sprite here, or click to choose — nothing leaves your browser +
+ +
+ + +
+ + +
+ +
+ +
+

## Commands

+ + + + + + + +
processNormalize a single image, or an animated GIF with one grid and one palette shared across frames. - pipes through stdin/stdout.
analyzeGrid, background, and color inspection with zero writes.
batchParallel directory/glob processing; collisions detected up front, partial failures itemized.
sheetSplit, normalize, and reassemble sprite sheets — fixed grid or auto-split.
paletteList built-ins, extract from an image, fetch from Lospec.
tuiInteractive terminal editor (build with --features tui).
+ +

## The flags that matter

+ + + + + + + + + + + + +
--grid-size 10.667Force a pitch, fractional welcome. --grid-phase X,Y for the offset.
--coarsen 2Multiply the final pitch (detected or forced) by an integer: detection finds the render quantum, but the intended art resolution is often 2x coarser. Phase preserved; no effect when detection declines.
--downscale-modesnap (default, keeps resolution) · majority-vote · center-weighted · center-pixel
--logical-sizeReduced modes: emit true logical resolution instead of the crisp integer re-upscale.
--colors N / --palette / --lospec / --palette-fileMutually exclusive palette sources.
--remove-bgBorder-clustered background removal with de-fringing. --bg-color, --bg-tolerance, --no-flood-fill tune it.
--chroma-key HEXGreen-screen removal, everywhere in the image. Repeatable; --chroma-tolerance tunes the match.
--keep-alphaPreserve per-pixel alpha instead of binarizing blocks opaque/transparent.
--flatten-ditherTurn off dither preservation entirely.
--aa-threshold 0.5Enable AA removal; higher is more aggressive. --aa-passes for wide ramps.
--seed NDeterminism: same input + seed → identical bytes.
+

Full reference: pixfix help process or the + README. + Config files (.pixfix.toml) fill in anything the CLI doesn't set.

+
+ +
+

## For scripts & agents

+

+ The CLI is built to be driven blind. --json puts exactly one + machine-readable document on stdout — stable schema, fields always present — + and keeps logs on stderr. Exit codes are distinct, outputs are no-clobber by + default, and llms.txt / llms-full.txt + carry this whole manual in plain text. +

+
$ pixfix process sprite.png out.png --json
+{
+  "grid": { "pitch_x": 10.667, "phase": [0.0, 0.0], "confidence": 0.85, "source": "detected" },
+  "logical_size": [48, 48],
+  "colors_after": 16,
+  "low_confidence_blocks": 3,
+  "warnings": [],
+  "duration_ms": 412
+}
+ + + + +
0success4processing failed
2usage or config error5output exists (pass --overwrite)
3input unreadable6batch partially failed
+
+ +
+

## pixfix, the desktop app

+

+ The same pipeline with live before/after previews: drop an image in, scrub + the settings, watch it re-process. Grid scores, contested blocks, and color + histograms are right there in the diagnostics pane. Bundles ship for macOS, + Linux, and Windows on every release. +

+

+ Grab it from the releases page. +

+
+ +
+ + + + + + + diff --git a/docs/llms-full.txt b/docs/llms-full.txt new file mode 100644 index 0000000..d7baedb --- /dev/null +++ b/docs/llms-full.txt @@ -0,0 +1,264 @@ +# pixfix — complete plain-text manual + +Rust CLI + desktop app (pixfix) that turns AI-generated "pixel art" — where +the apparent pixels are misaligned, noisy, and rendered at fractional sizes — +into clean, grid-aligned game assets. + +Install: cargo install pixfix +Binary releases: https://github.com/lovelaced/pixfix/releases +Browser demo: https://lovelaced.github.io/pixfix/#demo — the pipeline +compiled to WebAssembly, entirely client-side (images never leave the page). + + +## PIPELINE (stage order) + +1. Grid detection — estimates pitch (pixels per logical pixel, may be + fractional, e.g. 10.667) and phase per axis from gradient profiles. + Below a confidence floor it declines to snap and reports a best guess. + Overrides: --grid-size (fractional ok), --grid-phase X,Y. +2. AA removal (opt-in via --aa-threshold) — snaps interpolation-artifact + pixels to their dominant neighbor, multi-pass, with a dither guard. +3. Background/chroma mask — border-clustered background detection and/or + global chroma keys build a transparency mask at full resolution. +4. Grid normalization — snap mode paints each block its winning color at + original resolution; reduced modes emit one pixel per block. Alpha + binarizes per block by default. Masked pixels never vote. +5. Mask applied — background/keyed pixels become transparent. +6. Dither detection — alternating color pairs found on the logical grid. +7. Quantization — seeded weighted k-means over unique colors, or snapping + to a fixed palette; detected dither pairs are pinned so they survive. +8. Output sizing — snap keeps original dims; reduced modes emit logical + size x round(pitch) (crisp integer upscale) or logical size with + --logical-size. --target-width/--target-height force exact dims. + + +## COMMANDS + +pixfix process [OUTPUT] [flags] + Normalize one image. OUTPUT defaults to _normalized. + (extension always comes from --output-format, default png — never from + the input). INPUT "-" reads stdin; OUTPUT "-" writes PNG to stdout + (not combinable with --json). + Animated GIFs (frame count > 1) are normalized as one animation: the + grid is detected once on the first frame, the background color is + resolved once, and one palette is extracted from all frames together, + so nothing flickers between frames. Per-frame timing is preserved and + the output is always an animated GIF (derived name + _normalized.gif; --output-format may only be gif or left unset, + anything else exits 2). Static GIFs take the single-image path. + +pixfix analyze [--sheet] [flags] + Inspect without writing anything: grid pitch/phase/confidence, logical + size, unique colors, detected background and border coverage. --sheet + adds the auto-split sprite count. Honors grid overrides. + +pixfix batch [flags] + INPUT is a directory or glob (extensions match case-insensitively). + Parallel; one bad file never aborts the run. Output paths are planned up + front and collisions rejected (use --preserve-dirs to keep the input + tree, --suffix to change naming, '' allowed). Partial failure exits 6. + +pixfix sheet [OUTPUT] [flags] + Sprite sheets. Fixed grid: --tile-width/--tile-height (+ --spacing, + --margin). Auto-split: omit tile dims; tuned by --separator-threshold, + --min-sprite-size, --pad. --output-dir also saves individual sprites; + --no-normalize splits/reassembles without the pipeline. The sheet-level + grid is detected once and rebased per tile. + +pixfix palette list|extract|fetch + list: built-in palettes (pico-8, sweetie-16, endesga-32, endesga-64, + gameboy, nes). extract --colors N [-o file.hex] [--overwrite]: + k-means medoid palette from an image, sorted, deterministic. + fetch [-o file.hex] [--refresh]: download from lospec.com, + cached in the platform cache dir. + +pixfix tui [INPUT] + Interactive terminal editor; requires building with --features tui. + + +## GLOBAL FLAGS + +--json one JSON result document on stdout; NDJSON progress + logs + on stderr. Stable schema; fields always present (null when + inapplicable). +-q, --quiet nothing but errors. +-v/-vv/-vvv tracing verbosity (stderr). RUST_LOG also honored. +--config P explicit config file (missing path = error, exit 2). +--no-config ignore config files entirely, including cwd discovery. + + +## PIPELINE FLAGS (process/analyze/batch/sheet) + +Note: `-h` prints a curated subset; `--help` lists every flag below. + +Grid: + --grid-size N force pitch; fractional accepted (10.667) + --grid-phase X,Y force phase (requires --grid-size) + --no-grid-detect skip detection (requires --grid-size) + --max-grid-candidate N max pitch searched (default scales with image) + --min-confidence 0..1 confidence floor (default 0.35) below which + detection declines to snap and only reports its + best guess; 0 accepts anything. Config key: + [grid] min_confidence. + --coarsen N multiply the FINAL pitch (auto-detected or from + --grid-size) by an integer >= 1. Exact semantics: + detection finds the render quantum (the grid the + generator painted on); the intended artistic + resolution is often a multiple of it. Phase is + preserved so coarse blocks align with the fine + grid. No effect when detection declines (nothing + to multiply). Reported grid values (incl. --json) + show the multiplied pitch; grid_best_guess keeps + the raw detection. Config key: [grid] coarsen. + +Downscale: + --downscale-mode M snap (default) | center-weighted | majority-vote + | center-pixel. snap keeps resolution; others + reduce to logical pixels. + --logical-size reduced modes: emit logical resolution instead of + the default integer re-upscale by round(pitch) + --keep-alpha preserve per-pixel alpha instead of binarizing + each block to fully opaque/transparent + +Anti-aliasing: + --aa-threshold 0..1 enable AA removal; HIGHER = more aggressive + --aa-passes N max passes (default 3), for wide AA ramps + +Color (sources are mutually exclusive): + --palette NAME | --palette-file F.hex | --lospec SLUG | --colors N + --no-quantize skip quantization (conflicts with sources) + --seed N RNG seed; same input + seed = identical bytes + --flatten-dither disable dither detection, pinning, and AA guard + +Background: + --remove-bg / --no-remove-bg border-clustered background removal + --bg-color HEX explicit background color (else auto-detected; + gradients/vignettes handled by OKLAB clustering) + --bg-threshold 0..1 border coverage needed for auto-detect (0.4) + --bg-tolerance N OKLAB match tolerance (0.05) + --flood-fill / --no-flood-fill border-connected only (default) vs + global replacement + +Chroma keying: + --chroma-key HEX remove this color EVERYWHERE (green-screen + style, interior regions included). Repeatable + for multiple keys. Combines with --remove-bg. + --chroma-tolerance N OKLAB match tolerance (0.05) + +Output: + --target-width N / --target-height N exact output dims + --output-format png|webp|bmp|gif encoding + derived extension + (animated inputs: gif only) + --overwrite / --no-overwrite overwrite policy (no-clobber + default, everywhere) + +Diagnostics: + --debug-overlay PATH (process only) also write a PNG of the source + dimmed with each block tinted red in proportion + to how contested its color vote was; a wrong + pitch/phase tints the whole frame, while tinting + confined to noisy regions means the grid is + right. Skipped (with a note) when no grid was + applied. + +Batch only: + --suffix S output stem suffix (default _normalized; '' ok) + --preserve-dirs recreate input tree under the output dir + + +## EXIT CODES + +0 success +2 usage or config error (clap errors and config problems) +3 input could not be read +4 processing/detection/encoding failed +5 output already exists (pass --overwrite) +6 batch completed with some files failed + + +## JSON OUTPUT (--json) + +process/sheet document fields: + input, output paths as given + grid {pitch_x, pitch_y, phase:[x,y], confidence, + source:"detected"|"override"} or null + grid_best_guess same shape; set when detection declined to snap + logical_size [w,h] or null + output_size [w,h] + frames frame count: 1 for stills, N for animated GIFs + palette "pico-8" | "file:..." | "lospec:..." | "auto:N" | null + colors_before, colors_after unique color counts (null if skipped) + aa_pixels_changed, aa_passes AA stats + bg_removed bool; bg_color "#RRGGBB" or null; + bg_pixels_removed count or null + dither_pairs [{color_a, color_b, alternating_blocks}] + low_confidence_blocks, total_blocks contested-vote stats + warnings human-readable strings + duration_ms pipeline wall time +sheet adds: sprites, tile_size [w,h], sprites_dir. + +analyze document: input, size, frames, grid, grid_best_guess, + logical_size, unique_colors, detected_bg, bg_border_coverage, + sheet_sprites, coarsen_candidates, suggested_coarsen, warnings. + frames is 1 for stills; animated GIFs are analyzed on their first + frame and report the animation's frame count. + coarsen_candidates: when a grid is accepted, analyze also snaps at + 1x/2x/4x of the detected pitch and reports each factor's contested-block + rate ({factor, pitch_x, logical_size, contested_rate}). The detected + pitch is the render quantum; a low rate at a multiple means the content + also reads cleanly at that coarser artistic resolution. + suggested_coarsen: largest factor whose rate stays within 15 percentage + points of the 1x baseline and below 50% absolute; null when only 1x + qualifies. The choice between qualifying factors is taste — use the + published rates. + +batch document: {summary: {total, succeeded, failed, skipped}, + files: [{input, output, status:"ok"|"failed"|"skipped", error}]}. +Batch NDJSON progress on stderr: {"event":"batch_started",...} then + {"event":"file_done","index":..,"total":..,"input":..,"output":.., + "status":..,"error":..} per file. + + +## CONFIG FILE (.pixfix.toml, cwd-discovered or --config) + +Precedence: CLI flags > config file > built-in defaults. + +[grid] size (fractional ok), phase_x, phase_y, max_candidate, + coarsen, skip +[aa] threshold, skip +[quantize] colors, palette, skip +[background] enabled, color, border_threshold, color_tolerance, + flood_fill, chroma_keys = ["FF00FF", ...], chroma_tolerance +[output] overwrite +[sheet] separator_threshold, min_sprite_size, pad + + +## RECIPES + +Inspect, then process only if a grid was found: + pixfix analyze in.png --json | jq -e '.grid != null' \ + && pixfix process in.png out.png --json + +Force a known grid on a low-confidence image: + pixfix process in.png out.png --grid-size 10.667 + +Detected pitch is the render quantum; snap at 2x for era-honest resolution: + pixfix process in.png out.png --coarsen 2 + +True-resolution asset with a fixed palette and transparent background: + pixfix process in.png out.png --downscale-mode majority-vote \ + --logical-size --palette pico-8 --remove-bg + +Normalize an animated GIF (one grid + one palette across all frames, +timing preserved): + pixfix process anim.gif out.gif --colors 16 + +Green-screen removal (interior regions too), two keys: + pixfix process in.png out.png \ + --chroma-key 00FF00 --chroma-key FF00FF + +Reproducible batch with preserved directory structure: + pixfix batch "art/**/*.png" out/ --preserve-dirs --seed 7 + +Pipe through without touching disk: + cat in.png | pixfix process - - --quiet > out.png diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..385f227 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,26 @@ +# pixfix + +> Rust CLI and desktop app that normalizes AI-generated pixel art into clean, +> grid-aligned game assets. Detects the pixel grid (including fractional +> pitches like 10.667px), snaps blocks to it, removes anti-aliasing, quantizes +> colors while preserving dithering, and removes backgrounds or chroma keys. +> Built to be driven by scripts and agents: --json output with a stable +> schema, distinct exit codes, deterministic results via --seed, and an +> analyze subcommand that inspects without writing. + +Key facts: +- Install: `cargo install pixfix` (binary releases on GitHub). +- Basic use: `pixfix process input.png` writes `input_normalized.png`. +- Inspect first: `pixfix analyze input.png --json`. +- Exit codes: 0 ok, 2 usage/config, 3 input unreadable, 4 processing failed, + 5 output exists, 6 batch partial failure. +- With `--json`, exactly one JSON document goes to stdout; logs and NDJSON + progress go to stderr. +- Same input + `--seed N` always produces byte-identical output. +- Outputs never overwrite existing files unless `--overwrite` is passed. + +## Docs + +- [Full plain-text manual](https://lovelaced.github.io/pixfix/llms-full.txt): every command, flag, JSON field, and config key +- [README](https://raw.githubusercontent.com/lovelaced/pixfix/master/README.md): install, examples, CLI reference +- [Source](https://github.com/lovelaced/pixfix): GPL, Rust workspace (CLI library + Tauri desktop app) diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 0000000..9e1d341 --- /dev/null +++ b/docs/style.css @@ -0,0 +1,398 @@ +/* pixfix docs — Catppuccin Mocha, matching the pixfix app. */ + +:root { + --rosewater: #f5e0dc; + --pink: #f5c2e7; + --mauve: #cba6f7; + --red: #f38ba8; + --peach: #fab387; + --yellow: #f9e2af; + --green: #a6e3a1; + --teal: #94e2d5; + --blue: #89b4fa; + --lavender: #b4befe; + --text: #cdd6f4; + --subtext1: #bac2de; + --subtext0: #a6adc8; + --overlay0: #6c7086; + --surface2: #585b70; + --surface1: #45475a; + --surface0: #313244; + --base: #1e1e2e; + --mantle: #181825; + --crust: #11111b; + --mono: "JetBrains Mono", "SF Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + background: var(--base); + color: var(--text); + font-family: var(--mono); + font-size: 15px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +::selection { background: var(--surface2); } + +a { color: var(--blue); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.18s ease, color 0.18s ease; } +a:hover { color: var(--lavender); border-bottom-color: var(--lavender); } + +code { + font-family: var(--mono); + background: var(--surface0); + padding: 0.1em 0.4em; + border-radius: 4px; + font-size: 0.92em; + white-space: nowrap; +} + +pre { + background: var(--mantle); + border: 1px solid var(--surface0); + border-radius: 8px; + padding: 14px 18px; + overflow-x: auto; + line-height: 1.55; +} +pre code { background: none; padding: 0; white-space: pre; } + +.prompt { color: var(--green); user-select: none; } +.out { color: var(--subtext0); } +.dim { color: var(--overlay0); } +.accent { color: var(--mauve); } + +/* ── Nav ─────────────────────────────────────────────────────────────── */ + +.nav { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 24px; + background: color-mix(in srgb, var(--mantle) 88%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--surface0); +} + +.nav-brand { color: var(--mauve); font-weight: 700; border: none; } +.nav-brand:hover { color: var(--pink); } + +.nav-links { display: flex; gap: 18px; flex-wrap: wrap; } +.nav-links a { + color: var(--subtext0); + font-size: 13px; + border: none; + position: relative; + padding-bottom: 2px; +} +.nav-links a::after { + content: ""; + position: absolute; + left: 0; right: 100%; + bottom: 0; + height: 2px; + background: var(--mauve); + transition: right 0.25s cubic-bezier(0.22, 1, 0.36, 1); +} +.nav-links a:hover { color: var(--text); } +.nav-links a:hover::after, .nav-links a.active::after { right: 0; } +.nav-links a.active { color: var(--mauve); } +.nav-gh { color: var(--subtext1); } + +/* ── Hero ────────────────────────────────────────────────────────────── */ + +.hero { + max-width: 880px; + margin: 0 auto; + padding: 72px 24px 48px; + text-align: center; +} + +.hero h1 { + font-size: clamp(28px, 5vw, 42px); + color: var(--mauve); + letter-spacing: -0.02em; + margin-bottom: 16px; +} + +.tagline { + max-width: 640px; + margin: 0 auto 32px; + color: var(--subtext1); + text-align: left; +} + +.hero-install { + display: inline-flex; + flex-direction: column; + gap: 8px; + text-align: left; +} +.install-line { + display: block; + background: var(--mantle); + border: 1px solid var(--surface0); + border-radius: 8px; + padding: 10px 18px; + white-space: pre; + transition: border-color 0.2s ease; +} +.install-line:hover { border-color: var(--surface2); } + +/* The pixel-snap demo: cells scatter, then settle onto the grid. */ +.hero-grid-wrap { margin: 0 auto 40px; width: fit-content; } +.hero-grid { + display: grid; + grid-template-columns: repeat(10, 22px); + grid-auto-rows: 22px; + gap: 2px; + padding: 14px; + background: var(--mantle); + border: 1px solid var(--surface0); + border-radius: 10px; +} +.cell { border-radius: 2px; } +.cell.on { + opacity: 0.35; + filter: blur(1.5px); + transform: translate(var(--dx, 0), var(--dy, 0)) rotate(var(--rot, 0)) scale(0.8); + animation: settle 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +.cell.on.settled { animation: none; opacity: 1; filter: none; transform: none; } + +@keyframes settle { + 60% { opacity: 1; } + 100% { + opacity: 1; + filter: blur(0); + transform: translate(0, 0) rotate(0) scale(1); + } +} + +.hero-caption { + margin-top: 10px; + font-size: 12px; + color: var(--overlay0); +} + +/* ── Sections ────────────────────────────────────────────────────────── */ + +main { max-width: 880px; margin: 0 auto; padding: 0 24px; } + +.section { padding: 40px 0; border-top: 1px solid var(--surface0); } + +.section h2 { + font-size: 21px; + color: var(--lavender); + margin-bottom: 14px; +} +.section h2.mt { margin-top: 36px; } +.h-mark { color: var(--surface2); margin-right: 8px; } + +.section > p { max-width: 680px; color: var(--subtext1); margin-bottom: 18px; } +.fine { font-size: 13px; color: var(--subtext0); } + +/* Scroll reveal — gentle, once, disabled under reduced motion. */ +.section, .stage, .example { + opacity: 0; + transform: translateY(12px); + transition: opacity 0.55s ease-out, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1); +} +.visible { opacity: 1; transform: none; } + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .section, .stage, .example { opacity: 1; transform: none; transition: none; } + .cell.on { animation: none; opacity: 1; filter: none; transform: none; } + .nav-links a::after { transition: none; } +} + +/* Pipeline stages: a connected vertical list. */ +.stages { list-style: none; counter-reset: stage; } +.stage { + counter-increment: stage; + position: relative; + padding: 0 0 26px 52px; +} +.stage::before { + content: counter(stage); + position: absolute; + left: 0; + top: 0; + width: 32px; + height: 32px; + display: grid; + place-items: center; + background: var(--surface0); + color: var(--mauve); + font-weight: 700; + border-radius: 8px; + font-size: 14px; +} +.stage:not(:last-child)::after { + content: ""; + position: absolute; + left: 15px; + top: 38px; + bottom: 4px; + width: 2px; + background: var(--surface0); +} +.stage h3 { font-size: 16px; color: var(--text); margin-bottom: 4px; } +.stage p { color: var(--subtext0); font-size: 14px; max-width: 640px; } +.opt { + font-size: 11px; + color: var(--peach); + background: color-mix(in srgb, var(--peach) 12%, transparent); + border-radius: 4px; + padding: 2px 7px; + vertical-align: 2px; + margin-left: 6px; +} + +/* Examples */ +.example { margin-bottom: 20px; } +.example-label { color: var(--subtext1); font-size: 14px; margin-bottom: 8px; } + +/* Reference tables */ +.ref { width: 100%; border-collapse: collapse; font-size: 14px; } +.ref td { + padding: 9px 14px 9px 0; + border-bottom: 1px solid var(--surface0); + vertical-align: top; + color: var(--subtext1); +} +.ref td:first-child { white-space: nowrap; color: var(--text); } +.ref tr { transition: background 0.15s ease; } +.ref tr:hover { background: color-mix(in srgb, var(--surface0) 40%, transparent); } + +.exit-codes td:first-child, .exit-codes td:nth-child(3) { + font-weight: 700; + width: 3em; +} +.code-ok { color: var(--green); } +.code-warn { color: var(--peach); } + +/* JSON demo highlighting */ +.json-demo { margin-bottom: 20px; } +.j { color: var(--overlay0); } +.jk { color: var(--blue); } +.jv { color: var(--peach); } +.js { color: var(--green); } + +/* ── Try-it demo ─────────────────────────────────────────────────────── */ + +.demo-drop { + display: grid; + place-items: center; + min-height: 110px; + padding: 24px; + background: var(--mantle); + border: 2px dashed var(--surface2); + border-radius: 10px; + color: var(--subtext0); + font-size: 14px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; +} +.demo-drop:hover, +.demo-drop:focus-visible, +.demo-drop.drag { + border-color: var(--mauve); + color: var(--text); + background: color-mix(in srgb, var(--mauve) 6%, var(--mantle)); + outline: none; +} + +.demo-controls { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; + margin-top: 14px; + font-size: 14px; + color: var(--subtext1); +} +.demo-controls label { display: inline-flex; align-items: center; gap: 8px; } +.demo-controls input[type="number"], +.demo-controls select { + font-family: var(--mono); + font-size: 14px; + color: var(--text); + background: var(--surface0); + border: 1px solid var(--surface1); + border-radius: 6px; + padding: 4px 8px; + transition: border-color 0.18s ease; +} +.demo-controls input[type="number"] { width: 5em; } +.demo-controls input[type="number"]:focus, +.demo-controls select:focus { + border-color: var(--mauve); + outline: none; +} +.demo-note { color: var(--overlay0); font-size: 12px; } + +.demo-result { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 16px; +} +/* display: grid above would defeat the hidden attribute. */ +.demo-result[hidden] { display: none; } +.demo-panel { + margin: 0; + background: var(--mantle); + border: 1px solid var(--surface0); + border-radius: 8px; + padding: 10px; +} +.demo-panel img { + display: block; + width: 100%; + image-rendering: pixelated; + border-radius: 4px; + /* Checkerboard so transparent results read as transparent. */ + background: repeating-conic-gradient(var(--surface0) 0% 25%, var(--mantle) 0% 50%) 0 0 / 16px 16px; +} +.demo-panel figcaption { + margin-top: 8px; + font-size: 12px; + color: var(--overlay0); + text-align: center; +} + +.demo-meta { margin-top: 12px; font-size: 13px; color: var(--subtext0); } +.demo-fallback { margin-top: 4px; } + +/* ── Footer ──────────────────────────────────────────────────────────── */ + +.footer { + max-width: 880px; + margin: 0 auto; + padding: 28px 24px 44px; + border-top: 1px solid var(--surface0); + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + color: var(--overlay0); + font-size: 13px; +} + +@media (max-width: 560px) { + .nav { padding: 10px 14px; } + .nav-links { gap: 12px; } + .hero { padding-top: 48px; } + .hero-grid { grid-template-columns: repeat(10, 16px); grid-auto-rows: 16px; } + .demo-result { grid-template-columns: 1fr; } +} diff --git a/examples/gen_test_image.rs b/examples/gen_test_image.rs index 1644507..11a3d87 100644 --- a/examples/gen_test_image.rs +++ b/examples/gen_test_image.rs @@ -90,7 +90,10 @@ fn main() { } img.save("test_images/synthetic_ai_sprite.png").unwrap(); - println!("Generated test_images/synthetic_ai_sprite.png ({}x{})", big_w, big_h); + println!( + "Generated test_images/synthetic_ai_sprite.png ({}x{})", + big_w, big_h + ); // Also save the "ground truth" clean version let mut clean = RgbaImage::new(small_w, small_h); @@ -100,5 +103,8 @@ fn main() { } } clean.save("test_images/ground_truth.png").unwrap(); - println!("Generated test_images/ground_truth.png ({}x{})", small_w, small_h); + println!( + "Generated test_images/ground_truth.png ({}x{})", + small_w, small_h + ); } diff --git a/pixfix/src/lib.rs b/pixfix/src/lib.rs deleted file mode 100644 index 66cd4e7..0000000 --- a/pixfix/src/lib.rs +++ /dev/null @@ -1,777 +0,0 @@ -use std::io::Cursor; -use std::path::PathBuf; -use std::sync::Mutex; - -use base64::Engine; -use image::codecs::gif::{GifEncoder, Repeat}; -use image::{Delay, Frame, RgbaImage}; -use serde::{Deserialize, Serialize}; -use tauri::{Emitter, State}; - -use normalize_pixelart::color::lospec; -use normalize_pixelart::color::palettes::ALL_PALETTES; -use normalize_pixelart::config::parse_hex_color; -use normalize_pixelart::image_util::histogram::ColorHistogram; -use normalize_pixelart::image_util::io; -use normalize_pixelart::pipeline::{ - DownscaleMode, PipelineConfig, PipelineDiagnostics, run_pipeline, -}; - -// --------------------------------------------------------------------------- -// App state -// --------------------------------------------------------------------------- - -struct AppState { - original: Option, - processed: Option, - config: PipelineConfig, - diagnostics: Option, - unique_colors: usize, - sheet_tiles: Option>, -} - -impl Default for AppState { - fn default() -> Self { - Self { - original: None, - processed: None, - config: PipelineConfig::default(), - diagnostics: None, - unique_colors: 0, - sheet_tiles: None, - } - } -} - -// --------------------------------------------------------------------------- -// Serializable types for JS communication -// --------------------------------------------------------------------------- - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ImageInfo { - width: u32, - height: u32, - grid_size: Option, - grid_confidence: Option, - unique_colors: usize, - grid_scores: Vec<(u32, f32)>, - histogram: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProcessConfig { - grid_size: Option, - grid_phase_x: Option, - grid_phase_y: Option, - max_grid_candidate: Option, - no_grid_detect: bool, - downscale_mode: String, - aa_threshold: Option, - palette_name: Option, - auto_colors: Option, - custom_palette: Option>, - remove_bg: bool, - bg_color: Option, - border_threshold: Option, - no_quantize: bool, - bg_tolerance: f32, - flood_fill: bool, - output_scale: Option, - output_width: Option, - output_height: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LospecResult { - name: String, - slug: String, - num_colors: usize, - colors: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProcessResult { - width: u32, - height: u32, - grid_size: Option, - grid_confidence: Option, - unique_colors: usize, - grid_scores: Vec<(u32, f32)>, - histogram: Vec, -} - -#[derive(Serialize, Clone)] -struct ColorEntry { - hex: String, - r: u8, - g: u8, - b: u8, - percent: f64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PaletteInfo { - name: String, - slug: String, - num_colors: usize, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn build_histogram_entries(img: &RgbaImage, top_n: usize) -> (Vec, usize) { - let hist = ColorHistogram::from_image(img); - let total = hist.total_pixels() as f64; - let unique = hist.unique_colors(); - let entries = hist - .top_n(top_n) - .into_iter() - .map(|(rgba, count)| { - let [r, g, b, _] = rgba.0; - ColorEntry { - hex: format!("#{:02X}{:02X}{:02X}", r, g, b), - r, - g, - b, - percent: (count as f64 / total) * 100.0, - } - }) - .collect(); - (entries, unique) -} - -fn parse_downscale_mode(s: &str) -> DownscaleMode { - match s { - "center-weighted" => DownscaleMode::CenterWeighted, - "majority-vote" => DownscaleMode::MajorityVote, - "center-pixel" => DownscaleMode::CenterPixel, - _ => DownscaleMode::Snap, - } -} - -fn build_config(pc: &ProcessConfig, original_width: u32, original_height: u32) -> PipelineConfig { - let mut config = PipelineConfig::default(); - - // Grid detection - if pc.no_grid_detect { - config.grid.skip = true; - } - if let Some(gs) = pc.grid_size { - config.grid.override_size = Some(gs); - } - if let (Some(px), Some(py)) = (pc.grid_phase_x, pc.grid_phase_y) { - config.grid.override_phase = Some((px, py)); - } - if let Some(mc) = pc.max_grid_candidate { - config.grid.max_candidate = mc; - } - - config.downscale_mode = parse_downscale_mode(&pc.downscale_mode); - - if let Some(thresh) = pc.aa_threshold { - config.aa.skip = false; - config.aa.threshold = thresh; - } else { - config.aa.skip = true; - } - - if let Some(ref name) = pc.palette_name { - config.quantize.palette_name = Some(name.clone()); - config.quantize.skip = false; - } else if let Some(ref hex_colors) = pc.custom_palette { - let mut rgb_colors = Vec::with_capacity(hex_colors.len()); - for hex in hex_colors { - if let Ok(rgb) = parse_hex_color(hex) { - rgb_colors.push(rgb); - } - } - if !rgb_colors.is_empty() { - config.quantize.custom_palette = Some(rgb_colors); - config.quantize.skip = false; - } - } else if let Some(n) = pc.auto_colors { - config.quantize.num_colors = Some(n); - config.quantize.skip = false; - } else { - config.quantize.skip = true; - } - if pc.no_quantize { - config.quantize.skip = true; - } - // Ensure defaults for k-means - config.quantize = config.quantize.with_defaults(); - - config.background.enabled = pc.remove_bg; - config.background.color_tolerance = pc.bg_tolerance; - config.background.flood_fill = pc.flood_fill; - if let Some(ref hex) = pc.bg_color { - if let Ok(rgb) = parse_hex_color(hex) { - config.background.bg_color = Some(rgb); - } - } - if let Some(bt) = pc.border_threshold { - config.background.border_threshold = bt; - } - - // Output resize: explicit dimensions take priority over scale - if pc.output_width.is_some() || pc.output_height.is_some() { - config.output_width = pc.output_width; - config.output_height = pc.output_height; - } else if let Some(scale) = pc.output_scale { - if scale > 1 { - config.output_width = Some(original_width * scale); - config.output_height = Some(original_height * scale); - } - } - - config -} - -fn encode_png(img: &RgbaImage) -> Result, String> { - let mut buf = Cursor::new(Vec::new()); - img.write_to(&mut buf, image::ImageFormat::Png) - .map_err(|e| e.to_string())?; - Ok(buf.into_inner()) -} - -fn build_gif_bytes( - tiles: &[(u32, u32, RgbaImage)], - mode: &str, - row: Option, - fps: u32, -) -> Result, String> { - if fps == 0 || fps > 100 { - return Err("FPS must be between 1 and 100".to_string()); - } - - // Select frames based on mode - let frame_images: Vec<&RgbaImage> = match mode { - "row" => { - let target_row = row.ok_or("Row number required for row mode")?; - let mut row_tiles: Vec<_> = tiles.iter().filter(|(_, r, _)| *r == target_row).collect(); - if row_tiles.is_empty() { - return Err(format!("No tiles found in row {}", target_row)); - } - row_tiles.sort_by_key(|(c, _, _)| *c); - row_tiles.into_iter().map(|(_, _, img)| img).collect() - } - "all" => { - let mut sorted: Vec<_> = tiles.iter().collect(); - sorted.sort_by_key(|(c, r, _)| (*r, *c)); - sorted.into_iter().map(|(_, _, img)| img).collect() - } - _ => return Err(format!("Unknown GIF mode: {}", mode)), - }; - - if frame_images.is_empty() { - return Err("No frames to encode".to_string()); - } - - // GIF delay: fps → milliseconds per frame - let delay_ms = 1000u32 / fps; - let delay = Delay::from_numer_denom_ms(delay_ms, 1); - - let frames: Vec = frame_images - .into_iter() - .map(|img| Frame::from_parts(img.clone(), 0, 0, delay)) - .collect(); - - let mut buf = Cursor::new(Vec::new()); - { - let mut encoder = GifEncoder::new(&mut buf); - encoder.set_repeat(Repeat::Infinite).map_err(|e| e.to_string())?; - encoder.encode_frames(frames).map_err(|e| e.to_string())?; - } - - Ok(buf.into_inner()) -} - -// --------------------------------------------------------------------------- -// Tauri commands -// --------------------------------------------------------------------------- - -#[tauri::command] -async fn open_image(path: String, state: State<'_, Mutex>) -> Result { - let img = io::load_image(std::path::Path::new(&path)).map_err(|e| e.to_string())?; - - // Run pipeline with default config - let pipeline_state = run_pipeline(img.clone(), &PipelineConfig::default()) - .map_err(|e| e.to_string())?; - - let processed = pipeline_state.image; - let (histogram, unique_colors) = build_histogram_entries(&processed, 20); - - let info = ImageInfo { - width: img.width(), - height: img.height(), - grid_size: pipeline_state.grid_size, - grid_confidence: pipeline_state.diagnostics.grid_confidence, - unique_colors, - grid_scores: pipeline_state.diagnostics.grid_variance_scores.clone(), - histogram, - }; - - let mut st = state.lock().unwrap(); - st.original = Some(img); - st.processed = Some(processed); - st.config = PipelineConfig::default(); - st.diagnostics = Some(pipeline_state.diagnostics); - st.unique_colors = unique_colors; - - Ok(info) -} - -#[tauri::command] -async fn process(pc: ProcessConfig, state: State<'_, Mutex>) -> Result { - let st = state.lock().unwrap(); - let original = st.original.as_ref().ok_or("No image loaded")?.clone(); - drop(st); - - let config = build_config(&pc, original.width(), original.height()); - let pipeline_state = run_pipeline(original, &config).map_err(|e| e.to_string())?; - - let processed = pipeline_state.image; - let (histogram, unique_colors) = build_histogram_entries(&processed, 20); - - let result = ProcessResult { - width: processed.width(), - height: processed.height(), - grid_size: pipeline_state.grid_size, - grid_confidence: pipeline_state.diagnostics.grid_confidence, - unique_colors, - grid_scores: pipeline_state.diagnostics.grid_variance_scores.clone(), - histogram, - }; - - let mut st = state.lock().unwrap(); - st.processed = Some(processed); - st.config = config; - st.diagnostics = Some(pipeline_state.diagnostics); - st.unique_colors = unique_colors; - - Ok(result) -} - -#[tauri::command] -async fn get_image(which: String, state: State<'_, Mutex>) -> Result, String> { - let st = state.lock().unwrap(); - let img = match which.as_str() { - "original" => st.original.as_ref().ok_or("No image loaded")?, - "processed" => st.processed.as_ref().ok_or("No processed image")?, - _ => return Err(format!("Unknown image type: {}", which)), - }; - encode_png(img) -} - -#[tauri::command] -async fn save_image(path: String, state: State<'_, Mutex>) -> Result<(), String> { - let st = state.lock().unwrap(); - let img = st.processed.as_ref().ok_or("No processed image to save")?; - io::save_image(img, &PathBuf::from(&path)).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn list_palettes() -> Vec { - ALL_PALETTES - .iter() - .map(|p| PaletteInfo { - name: p.name.to_string(), - slug: p.slug.to_string(), - num_colors: p.colors.len(), - }) - .collect() -} - -#[tauri::command] -async fn fetch_lospec(slug: String) -> Result { - let palette = lospec::fetch_lospec_palette(&slug)?; - Ok(LospecResult { - name: palette.name, - slug: palette.slug, - num_colors: palette.colors.len(), - colors: palette - .colors - .iter() - .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) - .collect(), - }) -} - -#[tauri::command] -fn get_palette_colors(slug: String) -> Result, String> { - let pal = ALL_PALETTES - .iter() - .find(|p| p.slug == slug) - .ok_or_else(|| format!("Unknown palette: {}", slug))?; - Ok(pal - .colors - .iter() - .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) - .collect()) -} - -#[tauri::command] -fn load_palette_file(path: String) -> Result, String> { - let content = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read palette file: {}", e))?; - let mut colors = Vec::new(); - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with(';') || line.starts_with('#') && line.len() < 2 { - continue; - } - if let Ok(rgb) = parse_hex_color(line) { - colors.push(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); - } - } - if colors.is_empty() { - return Err("No valid hex colors found in file".to_string()); - } - Ok(colors) -} - -// --------------------------------------------------------------------------- -// Batch processing -// --------------------------------------------------------------------------- - -#[derive(Serialize, Clone)] -#[serde(rename_all = "camelCase")] -struct BatchProgress { - current: u32, - total: u32, - filename: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BatchSummary { - succeeded: u32, - failed: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BatchFailure { - path: String, - error: String, -} - -#[tauri::command] -async fn batch_process( - input_paths: Vec, - output_dir: String, - pc: ProcessConfig, - overwrite: bool, - app: tauri::AppHandle, -) -> Result { - let out_dir = PathBuf::from(&output_dir); - std::fs::create_dir_all(&out_dir) - .map_err(|e| format!("Failed to create output directory: {}", e))?; - - let total = input_paths.len() as u32; - let mut succeeded = 0u32; - let mut failed = Vec::new(); - - for (i, input_path) in input_paths.iter().enumerate() { - let path = PathBuf::from(input_path); - let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - - let _ = app.emit("batch-progress", BatchProgress { - current: i as u32 + 1, - total, - filename: filename.clone(), - }); - - let stem = path.file_stem().unwrap_or_default().to_string_lossy(); - let ext = path.extension().unwrap_or_default().to_string_lossy(); - let ext = if ext.is_empty() { "png".to_string() } else { ext.to_string() }; - let out_path = out_dir.join(format!("{}_normalized.{}", stem, ext)); - - if out_path.exists() && !overwrite { - failed.push(BatchFailure { - path: input_path.clone(), - error: "Output already exists".to_string(), - }); - continue; - } - - match (|| -> Result<(), String> { - let image = io::load_image(&path).map_err(|e| format!("Load failed: {}", e))?; - let config = build_config(&pc, image.width(), image.height()); - let state = run_pipeline(image, &config).map_err(|e| format!("Pipeline failed: {}", e))?; - io::save_image(&state.image, &out_path).map_err(|e| format!("Save failed: {}", e))?; - Ok(()) - })() { - Ok(()) => succeeded += 1, - Err(e) => failed.push(BatchFailure { - path: input_path.clone(), - error: e, - }), - } - } - - Ok(BatchSummary { succeeded, failed }) -} - -// --------------------------------------------------------------------------- -// Sprite sheet processing -// --------------------------------------------------------------------------- - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SheetPreviewResult { - tile_count: u32, - tile_width: u32, - tile_height: u32, - cols: u32, - rows: u32, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SheetProcessResult { - tile_count: u32, - tile_width: u32, - tile_height: u32, - cols: u32, - rows: u32, - output_width: u32, - output_height: u32, -} - -#[tauri::command] -async fn sheet_preview( - mode: String, - tile_width: Option, - tile_height: Option, - spacing: Option, - margin: Option, - separator_threshold: Option, - min_sprite_size: Option, - pad: Option, - state: State<'_, Mutex>, -) -> Result { - let st = state.lock().unwrap(); - let original = st.original.as_ref().ok_or("No image loaded")?.clone(); - drop(st); - - use normalize_pixelart::spritesheet; - - match mode.as_str() { - "fixed" => { - let tw = tile_width.ok_or("tile_width required for fixed mode")?; - let th = tile_height.ok_or("tile_height required for fixed mode")?; - let sp = spacing.unwrap_or(0); - let mg = margin.unwrap_or(0); - let tiles = spritesheet::split_sheet(&original, tw, th, sp, mg); - let cols = if tiles.is_empty() { 0 } else { tiles.iter().map(|t| t.col).max().unwrap() + 1 }; - let rows = if tiles.is_empty() { 0 } else { tiles.iter().map(|t| t.row).max().unwrap() + 1 }; - Ok(SheetPreviewResult { - tile_count: tiles.len() as u32, - tile_width: tw, - tile_height: th, - cols, - rows, - }) - } - "auto" => { - let auto_config = spritesheet::AutoSplitConfig { - bg_color: Some([255, 255, 255]), - tolerance: 0.10, - separator_threshold: separator_threshold.unwrap_or(0.90), - min_sprite_size: min_sprite_size.unwrap_or(8), - pad: pad.unwrap_or(0), - }; - let (tiles, tw, th) = spritesheet::auto_split_sheet(&original, &auto_config) - .map_err(|e| e.to_string())?; - let cols = if tiles.is_empty() { 0 } else { tiles.iter().map(|t| t.col).max().unwrap() + 1 }; - let rows = if tiles.is_empty() { 0 } else { tiles.iter().map(|t| t.row).max().unwrap() + 1 }; - Ok(SheetPreviewResult { - tile_count: tiles.len() as u32, - tile_width: tw, - tile_height: th, - cols, - rows, - }) - } - _ => Err(format!("Unknown sheet mode: {}", mode)), - } -} - -#[tauri::command] -async fn sheet_process( - mode: String, - tile_width: Option, - tile_height: Option, - spacing: Option, - margin: Option, - separator_threshold: Option, - min_sprite_size: Option, - pad: Option, - no_normalize: Option, - pc: ProcessConfig, - state: State<'_, Mutex>, -) -> Result { - let st = state.lock().unwrap(); - let original = st.original.as_ref().ok_or("No image loaded")?.clone(); - drop(st); - - use normalize_pixelart::spritesheet; - - let skip_pipeline = no_normalize.unwrap_or(false); - let config = build_config(&pc, original.width(), original.height()); - - match mode.as_str() { - "fixed" => { - let tw = tile_width.ok_or("tile_width required for fixed mode")?; - let th = tile_height.ok_or("tile_height required for fixed mode")?; - let sp = spacing.unwrap_or(0); - let mg = margin.unwrap_or(0); - - let (result, processed_tiles, actual_tw, actual_th) = if skip_pipeline { - let tiles = spritesheet::split_sheet(&original, tw, th, sp, mg); - let sheet = spritesheet::assemble_sheet(&tiles, tw, th, sp, mg); - (sheet, tiles, tw, th) - } else { - spritesheet::process_sheet(&original, tw, th, sp, mg, &config) - .map_err(|e| e.to_string())? - }; - - let out_w = result.width(); - let out_h = result.height(); - let cols = if processed_tiles.is_empty() { 0 } else { processed_tiles.iter().map(|t| t.col).max().unwrap() + 1 }; - let rows = if processed_tiles.is_empty() { 0 } else { processed_tiles.iter().map(|t| t.row).max().unwrap() + 1 }; - let tile_count = processed_tiles.len() as u32; - - let sheet_tiles: Vec<(u32, u32, RgbaImage)> = processed_tiles - .into_iter() - .map(|t| (t.col, t.row, t.image)) - .collect(); - - let mut st = state.lock().unwrap(); - st.processed = Some(result); - st.sheet_tiles = Some(sheet_tiles); - - Ok(SheetProcessResult { tile_count, tile_width: actual_tw, tile_height: actual_th, cols, rows, output_width: out_w, output_height: out_h }) - } - "auto" => { - let auto_config = spritesheet::AutoSplitConfig { - bg_color: Some([255, 255, 255]), - tolerance: 0.10, - separator_threshold: separator_threshold.unwrap_or(0.90), - min_sprite_size: min_sprite_size.unwrap_or(8), - pad: pad.unwrap_or(0), - }; - let pipeline_ref = if skip_pipeline { None } else { Some(&config) }; - let (result, tiles, tw, th) = spritesheet::process_sheet_auto(&original, &auto_config, pipeline_ref) - .map_err(|e| e.to_string())?; - let out_w = result.width(); - let out_h = result.height(); - let cols = if tiles.is_empty() { 0 } else { tiles.iter().map(|t| t.col).max().unwrap() + 1 }; - let rows = if tiles.is_empty() { 0 } else { tiles.iter().map(|t| t.row).max().unwrap() + 1 }; - let tile_count = tiles.len() as u32; - - let sheet_tiles: Vec<(u32, u32, RgbaImage)> = tiles - .into_iter() - .map(|t| (t.col, t.row, t.image)) - .collect(); - - let mut st = state.lock().unwrap(); - st.processed = Some(result); - st.sheet_tiles = Some(sheet_tiles); - - Ok(SheetProcessResult { tile_count, tile_width: tw, tile_height: th, cols, rows, output_width: out_w, output_height: out_h }) - } - _ => Err(format!("Unknown sheet mode: {}", mode)), - } -} - -#[tauri::command] -async fn sheet_save_tiles(output_dir: String, state: State<'_, Mutex>) -> Result { - let st = state.lock().unwrap(); - let tiles = st.sheet_tiles.as_ref().ok_or("No sheet tiles available")?; - - let out_dir = PathBuf::from(&output_dir); - std::fs::create_dir_all(&out_dir) - .map_err(|e| format!("Failed to create output directory: {}", e))?; - - let mut count = 0u32; - for (col, row, img) in tiles { - let path = out_dir.join(format!("tile_{}_{}.png", row, col)); - io::save_image(img, &path).map_err(|e| format!("Failed to save tile: {}", e))?; - count += 1; - } - Ok(count) -} - -#[tauri::command] -async fn sheet_generate_gif( - mode: String, - row: Option, - fps: u32, - state: State<'_, Mutex>, -) -> Result { - let st = state.lock().unwrap(); - let tiles = st - .sheet_tiles - .as_ref() - .ok_or("No sheet tiles available. Process a sheet first.")?; - - let gif_bytes = build_gif_bytes(tiles, &mode, row, fps)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(&gif_bytes); - Ok(format!("data:image/gif;base64,{}", b64)) -} - -#[tauri::command] -async fn sheet_export_gif( - path: String, - mode: String, - row: Option, - fps: u32, - state: State<'_, Mutex>, -) -> Result<(), String> { - let st = state.lock().unwrap(); - let tiles = st - .sheet_tiles - .as_ref() - .ok_or("No sheet tiles available. Process a sheet first.")?; - - let gif_bytes = build_gif_bytes(tiles, &mode, row, fps)?; - std::fs::write(&path, &gif_bytes).map_err(|e| format!("Failed to write GIF: {}", e))?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// App setup -// --------------------------------------------------------------------------- - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .manage(Mutex::new(AppState::default())) - .invoke_handler(tauri::generate_handler![ - open_image, - process, - get_image, - save_image, - list_palettes, - fetch_lospec, - get_palette_colors, - load_palette_file, - batch_process, - sheet_preview, - sheet_process, - sheet_save_tiles, - sheet_generate_gif, - sheet_export_gif, - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/pixfix/ui/app.js b/pixfix/ui/app.js deleted file mode 100644 index f669396..0000000 --- a/pixfix/ui/app.js +++ /dev/null @@ -1,1713 +0,0 @@ -// pixfix/ui/src/app.ts -var { invoke } = window.__TAURI__.core; -var { open: openDialog, save: saveDialog } = window.__TAURI__.dialog; -var state = { - activeTab: "preview", - imageLoaded: false, - imagePath: null, - imageInfo: null, - settingsFocusIndex: 0, - processing: false, - palettes: [], - paletteIndex: 0, - config: { - gridSize: null, - gridPhaseX: null, - gridPhaseY: null, - maxGridCandidate: 32, - noGridDetect: false, - downscaleMode: "snap", - aaThreshold: null, - paletteName: null, - autoColors: null, - lospecSlug: null, - customPalette: null, - noQuantize: false, - removeBg: false, - bgColor: null, - borderThreshold: null, - bgTolerance: 0.05, - floodFill: true, - outputScale: null, - outputWidth: null, - outputHeight: null - }, - lospecResult: null, - lospecError: null, - lospecLoading: false, - paletteColors: null, - showAllHelp: false, - lastProcessTime: null, - batchFiles: [], - batchOutputDir: null, - batchRunning: false, - batchProgress: null, - batchResult: null, - sheetMode: "auto", - sheetConfig: { - tileWidth: null, - tileHeight: null, - spacing: 0, - margin: 0, - separatorThreshold: 0.9, - minSpriteSize: 8, - pad: 0, - noNormalize: false - }, - sheetPreview: null, - sheetProcessing: false, - gifMode: "row", - gifRow: 0, - gifFps: 10, - gifPreviewUrl: null, - gifGenerating: false -}; -var DEFAULT_CONFIG = JSON.parse(JSON.stringify(state.config)); -var DOWNSCALE_MODES = ["snap", "center-weighted", "majority-vote", "center-pixel"]; -function getSettings() { - const c = state.config; - return [ - { section: "Grid Detection" }, - { - key: "gridSize", - label: "Grid Size", - value: c.gridSize === null ? "auto" : String(c.gridSize), - help: 'How many screen pixels make up one "logical" pixel in your art. Auto-detection works well for most images. Override if the grid looks wrong.', - changed: c.gridSize !== null - }, - { - key: "gridPhaseX", - label: "Phase X", - value: c.gridPhaseX === null ? "auto" : String(c.gridPhaseX), - help: "Override the X offset of the grid alignment. Usually auto-detected.", - changed: c.gridPhaseX !== null - }, - { - key: "gridPhaseY", - label: "Phase Y", - value: c.gridPhaseY === null ? "auto" : String(c.gridPhaseY), - help: "Override the Y offset of the grid alignment. Usually auto-detected.", - changed: c.gridPhaseY !== null - }, - { - key: "noGridDetect", - label: "Skip Grid", - value: c.noGridDetect ? "on" : "off", - help: "Skip grid detection entirely. Useful if your image is already at logical resolution.", - changed: c.noGridDetect - }, - { - key: "maxGridCandidate", - label: "Max Grid", - value: String(c.maxGridCandidate), - help: "Maximum grid size to test during auto-detection (default: 32).", - changed: c.maxGridCandidate !== 32 - }, - { - key: "downscaleMode", - label: "Mode", - value: c.downscaleMode, - help: 'How to combine pixels in each grid cell. "snap" cleans in-place at original resolution. Others reduce to logical pixel resolution.', - changed: c.downscaleMode !== "snap" - }, - { section: "Anti-Aliasing" }, - { - key: "aaThreshold", - label: "AA Removal", - value: c.aaThreshold === null ? "off" : c.aaThreshold.toFixed(2), - help: "Removes soft blending between colors added by AI generators. Lower values are more aggressive. Try 0.30\u20130.50 for most images.", - changed: c.aaThreshold !== null - }, - { section: "Color Palette" }, - { - key: "paletteName", - label: "Palette", - value: c.paletteName === null ? "none" : c.paletteName, - help: "Snap all colors to a classic pixel art palette. Mutually exclusive with Lospec and Auto Colors.", - changed: c.paletteName !== null - }, - { - key: "lospecSlug", - label: "Lospec", - value: c.lospecSlug === null ? "none" : c.lospecSlug, - help: 'Load any palette from lospec.com by slug (e.g. "pico-8", "endesga-32"). Press Enter to type a slug and fetch it.', - changed: c.lospecSlug !== null - }, - { - key: "autoColors", - label: "Auto Colors", - value: c.autoColors === null ? "off" : String(c.autoColors), - help: "Auto-extract the best N colors from your image using k-means clustering in OKLAB color space.", - changed: c.autoColors !== null - }, - { - key: "paletteFile", - label: "Load .hex", - value: c.customPalette && !c.lospecSlug ? `${c.customPalette.length} colors` : "none", - help: "Load a palette from a .hex file (one hex color per line). Overrides palette and auto colors.", - changed: c.customPalette !== null && c.lospecSlug === null - }, - { - key: "noQuantize", - label: "Skip Quantize", - value: c.noQuantize ? "on" : "off", - help: "Skip color quantization entirely. Useful if you only want grid snapping and AA removal without palette changes.", - changed: c.noQuantize - }, - { section: "Background" }, - { - key: "removeBg", - label: "Remove BG", - value: c.removeBg ? "on" : "off", - help: "Detect and make the background transparent. The dominant border color is treated as background.", - changed: c.removeBg - }, - { - key: "bgColor", - label: "BG Color", - value: c.bgColor === null ? "auto" : c.bgColor, - help: 'Explicit background color as hex (e.g. "#FF00FF"). If auto, detects from border pixels.', - changed: c.bgColor !== null - }, - { - key: "borderThreshold", - label: "Border Thresh", - value: c.borderThreshold === null ? "0.40" : c.borderThreshold.toFixed(2), - help: "Fraction of border pixels that must match for auto-detection (0.0\u20131.0, default: 0.40).", - changed: c.borderThreshold !== null - }, - { - key: "bgTolerance", - label: "BG Tolerance", - value: c.bgTolerance.toFixed(2), - help: "How different a pixel can be from the background color and still count as background. Higher = more aggressive.", - changed: c.bgTolerance !== 0.05 - }, - { - key: "floodFill", - label: "Flood Fill", - value: c.floodFill ? "on" : "off", - help: "On: only remove connected background from edges. Off: remove matching color everywhere.", - changed: !c.floodFill - }, - { section: "Output" }, - { - key: "outputScale", - label: "Scale", - value: c.outputScale === null ? "off" : c.outputScale + "x", - help: "Scale the output by an integer multiplier (2x, 3x, etc). Great for upscaling sprites for game engines.", - changed: c.outputScale !== null - }, - { - key: "outputWidth", - label: "Width", - value: c.outputWidth === null ? "auto" : String(c.outputWidth), - help: "Explicit output width in pixels. Overrides scale.", - changed: c.outputWidth !== null - }, - { - key: "outputHeight", - label: "Height", - value: c.outputHeight === null ? "auto" : String(c.outputHeight), - help: "Explicit output height in pixels. Overrides scale.", - changed: c.outputHeight !== null - } - ]; -} -function getSettingRows() { - return getSettings().filter((s) => !s.section); -} -function adjustSetting(key, direction) { - const c = state.config; - switch (key) { - case "gridSize": - if (c.gridSize === null) { - c.gridSize = state.imageInfo?.gridSize || 4; - } else { - c.gridSize = Math.max(1, c.gridSize + direction); - if (c.gridSize === 1 && direction < 0) - c.gridSize = null; - } - break; - case "gridPhaseX": - if (c.gridPhaseX === null) { - c.gridPhaseX = 0; - } else { - c.gridPhaseX = Math.max(0, c.gridPhaseX + direction); - } - break; - case "gridPhaseY": - if (c.gridPhaseY === null) { - c.gridPhaseY = 0; - } else { - c.gridPhaseY = Math.max(0, c.gridPhaseY + direction); - } - break; - case "maxGridCandidate": - c.maxGridCandidate = Math.max(2, Math.min(64, c.maxGridCandidate + direction * 4)); - break; - case "noGridDetect": - c.noGridDetect = !c.noGridDetect; - break; - case "downscaleMode": { - let idx = DOWNSCALE_MODES.indexOf(c.downscaleMode); - idx = (idx + direction + DOWNSCALE_MODES.length) % DOWNSCALE_MODES.length; - c.downscaleMode = DOWNSCALE_MODES[idx]; - break; - } - case "aaThreshold": - if (c.aaThreshold === null) { - c.aaThreshold = 0.5; - } else { - c.aaThreshold = Math.round((c.aaThreshold + direction * 0.05) * 100) / 100; - if (c.aaThreshold <= 0) - c.aaThreshold = null; - else if (c.aaThreshold > 1) - c.aaThreshold = 1; - } - break; - case "paletteName": { - const names = [null, ...state.palettes.map((p) => p.slug)]; - let idx = names.indexOf(c.paletteName); - idx = (idx + direction + names.length) % names.length; - c.paletteName = names[idx]; - if (c.paletteName !== null) { - c.autoColors = null; - c.lospecSlug = null; - c.customPalette = null; - state.lospecResult = null; - fetchPaletteColors(c.paletteName); - } else { - state.paletteColors = null; - } - break; - } - case "autoColors": - if (c.autoColors === null) { - c.autoColors = 16; - } else { - c.autoColors = Math.max(2, c.autoColors + direction * 2); - if (c.autoColors <= 2 && direction < 0) - c.autoColors = null; - else if (c.autoColors > 256) - c.autoColors = 256; - } - if (c.autoColors !== null) { - c.paletteName = null; - c.lospecSlug = null; - c.customPalette = null; - state.paletteColors = null; - state.lospecResult = null; - } - break; - case "removeBg": - c.removeBg = !c.removeBg; - break; - case "borderThreshold": - if (c.borderThreshold === null) { - c.borderThreshold = 0.4; - } else { - c.borderThreshold = Math.round((c.borderThreshold + direction * 0.05) * 100) / 100; - if (c.borderThreshold <= 0) - c.borderThreshold = null; - else if (c.borderThreshold > 1) - c.borderThreshold = 1; - } - break; - case "bgTolerance": - c.bgTolerance = Math.round((c.bgTolerance + direction * 0.01) * 100) / 100; - c.bgTolerance = Math.max(0.01, Math.min(0.5, c.bgTolerance)); - break; - case "floodFill": - c.floodFill = !c.floodFill; - break; - case "outputScale": - if (c.outputScale === null) { - c.outputScale = 2; - } else { - c.outputScale = c.outputScale + direction; - if (c.outputScale < 2) - c.outputScale = null; - else if (c.outputScale > 16) - c.outputScale = 16; - } - break; - case "outputWidth": - if (c.outputWidth === null) { - c.outputWidth = state.imageInfo?.width || 64; - } else { - c.outputWidth = Math.max(1, c.outputWidth + direction * 8); - } - break; - case "outputHeight": - if (c.outputHeight === null) { - c.outputHeight = state.imageInfo?.height || 64; - } else { - c.outputHeight = Math.max(1, c.outputHeight + direction * 8); - } - break; - } -} -async function fetchPaletteColors(slug) { - try { - const colors = await invoke("get_palette_colors", { slug }); - state.paletteColors = colors; - renderSettings(); - } catch { - state.paletteColors = null; - } -} -async function fetchLospec(slug) { - state.lospecLoading = true; - state.lospecError = null; - renderSettings(); - try { - const result = await invoke("fetch_lospec", { slug }); - state.lospecResult = result; - state.config.lospecSlug = slug; - state.config.customPalette = result.colors; - state.config.paletteName = null; - state.config.autoColors = null; - state.paletteColors = result.colors; - state.lospecLoading = false; - renderSettings(); - autoProcess(); - } catch (e) { - state.lospecError = String(e); - state.lospecLoading = false; - renderSettings(); - } -} -async function loadPaletteFileDialog() { - try { - const result = await openDialog({ - multiple: false, - filters: [{ - name: "Palette Files", - extensions: ["hex", "txt"] - }] - }); - if (result) { - const colors = await invoke("load_palette_file", { path: result }); - state.config.customPalette = colors; - state.config.paletteName = null; - state.config.autoColors = null; - state.config.lospecSlug = null; - state.lospecResult = null; - state.paletteColors = colors; - renderSettings(); - autoProcess(); - } - } catch (e) { - setStatus("Error loading palette: " + e, "error"); - } -} -function setStatus(msg, type = "") { - const el = document.getElementById("status-msg"); - el.textContent = msg; - el.className = "status-msg" + (type ? " " + type : ""); - const spinner = document.getElementById("status-spinner"); - if (type === "processing") { - spinner.classList.add("active"); - } else { - spinner.classList.remove("active"); - } -} -function showWelcomeLoading() { - document.getElementById("welcome-loading").style.display = "flex"; -} -function hideWelcomeLoading() { - document.getElementById("welcome-loading").style.display = "none"; -} -function switchTab(name) { - state.activeTab = name; - document.querySelectorAll(".tab").forEach((t) => { - t.classList.toggle("active", t.dataset.tab === name); - }); - document.querySelectorAll(".tab-panel").forEach((p) => { - p.classList.toggle("active", p.id === "panel-" + name); - }); - if (name === "batch") - renderBatch(); - if (name === "sheet") - renderSheet(); -} -var SELECT_SETTINGS = ["downscaleMode", "paletteName"]; -var BOOLEAN_SETTINGS = ["removeBg", "floodFill", "noGridDetect", "noQuantize"]; -var INPUT_SETTINGS = ["gridSize", "gridPhaseX", "gridPhaseY", "maxGridCandidate", "aaThreshold", "autoColors", "bgColor", "borderThreshold", "bgTolerance", "lospecSlug", "outputScale", "outputWidth", "outputHeight"]; -var FILE_SETTINGS = ["paletteFile"]; -var NULLABLE_SETTINGS = { - gridSize: { offLabel: "auto", defaultValue: () => state.imageInfo?.gridSize || 4 }, - gridPhaseX: { offLabel: "auto", defaultValue: () => 0 }, - gridPhaseY: { offLabel: "auto", defaultValue: () => 0 }, - aaThreshold: { offLabel: "off", defaultValue: () => 0.5 }, - autoColors: { offLabel: "off", defaultValue: () => 16 }, - lospecSlug: { offLabel: "none", defaultValue: () => null }, - bgColor: { offLabel: "auto", defaultValue: () => null }, - borderThreshold: { offLabel: "0.40", defaultValue: () => 0.4 }, - outputScale: { offLabel: "off", defaultValue: () => 2 }, - outputWidth: { offLabel: "auto", defaultValue: () => state.imageInfo?.width || 64 }, - outputHeight: { offLabel: "auto", defaultValue: () => state.imageInfo?.height || 64 } -}; -function renderSettings() { - const list = document.getElementById("settings-list"); - const focused = document.activeElement; - if (focused && focused.classList?.contains("setting-inline-input") && list.contains(focused)) { - updateSettingsFocusOnly(list); - return; - } - const settings = getSettings(); - let rowIndex = 0; - let html = ""; - for (const s of settings) { - if (s.section) { - html += `
${s.section}
`; - } else { - const isFocused = rowIndex === state.settingsFocusIndex ? " focused" : ""; - const changed = s.changed ? " changed" : ""; - html += `
`; - html += ``; - html += `${s.label}`; - html += ``; - if (SELECT_SETTINGS.includes(s.key)) { - html += renderInlineSelect(s.key); - } else if (BOOLEAN_SETTINGS.includes(s.key)) { - html += `${escapeHtml(s.value)}`; - } else if (FILE_SETTINGS.includes(s.key)) { - if (s.changed) { - html += escapeHtml(s.value); - html += `\xD7`; - } else { - html += `${escapeHtml(s.value)}`; - } - } else if (INPUT_SETTINGS.includes(s.key)) { - html += renderInlineInput(s.key); - if (s.key in NULLABLE_SETTINGS && s.changed) { - const nullable = NULLABLE_SETTINGS[s.key]; - html += `\xD7`; - } - } else { - html += escapeHtml(s.value); - } - html += ``; - html += `
`; - html += `
${s.help}
`; - if ((s.key === "paletteName" || s.key === "lospecSlug" || s.key === "paletteFile") && state.paletteColors && state.paletteColors.length > 0) { - if (s.key === "paletteName" && state.config.paletteName !== null || s.key === "lospecSlug" && state.config.lospecSlug !== null || s.key === "paletteFile" && state.config.customPalette !== null && state.config.lospecSlug === null) { - html += renderPaletteSwatches(state.paletteColors); - } - } - if (s.key === "lospecSlug") { - if (state.lospecLoading) { - html += `
Fetching palette...
`; - } else if (state.lospecError) { - html += `
${escapeHtml(state.lospecError)}
`; - } else if (state.lospecResult && state.config.lospecSlug) { - html += `
${escapeHtml(state.lospecResult.name)} \u2014 ${state.lospecResult.numColors} colors
`; - } - } - rowIndex++; - } - } - list.innerHTML = html; -} -function updateSettingsFocusOnly(list) { - const rows = list.querySelectorAll(".setting-row"); - rows.forEach((row, i) => { - row.classList.toggle("focused", i === state.settingsFocusIndex); - }); -} -function renderPaletteSwatches(colors) { - let html = '
'; - for (const color of colors) { - html += `
`; - } - html += "
"; - return html; -} -function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} -function renderInlineSelect(key) { - const c = state.config; - switch (key) { - case "downscaleMode": { - const opts = DOWNSCALE_MODES.map((m) => ``).join(""); - return ``; - } - case "paletteName": { - let opts = ``; - opts += state.palettes.map((p) => ``).join(""); - return ``; - } - default: - return ""; - } -} -function renderInlineInput(key) { - const c = state.config; - switch (key) { - case "gridSize": { - const val = c.gridSize === null ? "" : c.gridSize; - return ``; - } - case "gridPhaseX": { - const val = c.gridPhaseX === null ? "" : c.gridPhaseX; - return ``; - } - case "gridPhaseY": { - const val = c.gridPhaseY === null ? "" : c.gridPhaseY; - return ``; - } - case "maxGridCandidate": { - return ``; - } - case "aaThreshold": { - const val = c.aaThreshold === null ? "" : c.aaThreshold.toFixed(2); - return ``; - } - case "autoColors": { - const val = c.autoColors === null ? "" : c.autoColors; - return ``; - } - case "bgColor": { - const val = c.bgColor ?? ""; - return ``; - } - case "borderThreshold": { - const val = c.borderThreshold === null ? "" : c.borderThreshold.toFixed(2); - return ``; - } - case "bgTolerance": { - const val = c.bgTolerance.toFixed(2); - return ``; - } - case "outputScale": { - const val = c.outputScale === null ? "" : c.outputScale; - return ``; - } - case "outputWidth": { - const val = c.outputWidth === null ? "" : c.outputWidth; - return ``; - } - case "outputHeight": { - const val = c.outputHeight === null ? "" : c.outputHeight; - return ``; - } - case "lospecSlug": { - const val = c.lospecSlug ?? ""; - return ``; - } - default: - return ""; - } -} -function startEditing(key) { - if (BOOLEAN_SETTINGS.includes(key)) { - adjustSetting(key, 1); - renderSettings(); - autoProcess(); - return; - } - if (SELECT_SETTINGS.includes(key)) { - return; - } - if (FILE_SETTINGS.includes(key)) { - if (key === "paletteFile") { - loadPaletteFileDialog(); - } - return; - } - if (INPUT_SETTINGS.includes(key)) { - const input = document.querySelector(`.setting-inline-input[data-key="${key}"]`); - if (input) { - input.focus(); - input.select(); - } - return; - } -} -function clearSetting(key) { - const c = state.config; - switch (key) { - case "gridSize": - c.gridSize = null; - break; - case "gridPhaseX": - c.gridPhaseX = null; - break; - case "gridPhaseY": - c.gridPhaseY = null; - break; - case "aaThreshold": - c.aaThreshold = null; - break; - case "autoColors": - c.autoColors = null; - break; - case "lospecSlug": - c.lospecSlug = null; - c.customPalette = null; - state.lospecResult = null; - state.paletteColors = null; - break; - case "paletteFile": - if (!c.lospecSlug) { - c.customPalette = null; - state.paletteColors = null; - } - break; - case "bgColor": - c.bgColor = null; - break; - case "borderThreshold": - c.borderThreshold = null; - break; - case "outputScale": - c.outputScale = null; - break; - case "outputWidth": - c.outputWidth = null; - break; - case "outputHeight": - c.outputHeight = null; - break; - } - renderSettings(); - autoProcess(); -} -function commitEdit(key, rawValue) { - const c = state.config; - const val = rawValue.trim(); - switch (key) { - case "gridSize": - if (val === "" || val === "auto") { - c.gridSize = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 1) - c.gridSize = n; - } - break; - case "gridPhaseX": - if (val === "" || val === "auto") { - c.gridPhaseX = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 0) - c.gridPhaseX = n; - } - break; - case "gridPhaseY": - if (val === "" || val === "auto") { - c.gridPhaseY = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 0) - c.gridPhaseY = n; - } - break; - case "maxGridCandidate": { - const n = parseInt(val); - if (!isNaN(n) && n >= 2) - c.maxGridCandidate = Math.min(64, n); - break; - } - case "aaThreshold": - if (val === "" || val === "off") { - c.aaThreshold = null; - } else { - const n = parseFloat(val); - if (!isNaN(n)) - c.aaThreshold = Math.max(0.01, Math.min(1, n)); - } - break; - case "autoColors": - if (val === "" || val === "off") { - c.autoColors = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 2) { - c.autoColors = Math.min(256, n); - c.paletteName = null; - c.lospecSlug = null; - c.customPalette = null; - state.paletteColors = null; - state.lospecResult = null; - } - } - break; - case "bgColor": - if (val === "" || val === "auto") { - c.bgColor = null; - } else { - const hex = val.startsWith("#") ? val : "#" + val; - if (/^#[0-9A-Fa-f]{6}$/.test(hex)) { - c.bgColor = hex.toUpperCase(); - } - } - break; - case "borderThreshold": - if (val === "" || val === "auto") { - c.borderThreshold = null; - } else { - const n = parseFloat(val); - if (!isNaN(n)) - c.borderThreshold = Math.max(0.01, Math.min(1, n)); - } - break; - case "bgTolerance": { - const n = parseFloat(val); - if (!isNaN(n)) - c.bgTolerance = Math.max(0.01, Math.min(0.5, n)); - break; - } - case "downscaleMode": - if (DOWNSCALE_MODES.includes(val)) - c.downscaleMode = val; - break; - case "paletteName": - c.paletteName = val === "" ? null : val; - if (c.paletteName !== null) { - c.autoColors = null; - c.lospecSlug = null; - c.customPalette = null; - state.lospecResult = null; - fetchPaletteColors(c.paletteName); - } else { - state.paletteColors = null; - } - break; - case "lospecSlug": - if (val === "" || val === "none") { - c.lospecSlug = null; - c.customPalette = null; - state.lospecResult = null; - state.paletteColors = null; - renderSettings(); - autoProcess(); - return; - } - fetchLospec(val); - return; - case "outputScale": - if (val === "" || val === "off" || val === "1") { - c.outputScale = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 2 && n <= 16) - c.outputScale = n; - } - break; - case "outputWidth": - if (val === "" || val === "auto") { - c.outputWidth = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 1) - c.outputWidth = n; - } - break; - case "outputHeight": - if (val === "" || val === "auto") { - c.outputHeight = null; - } else { - const n = parseInt(val); - if (!isNaN(n) && n >= 1) - c.outputHeight = n; - } - break; - } - renderSettings(); - autoProcess(); -} -function renderDiagnostics() { - const info = state.imageInfo; - if (!info) { - document.getElementById("diag-grid-info").innerHTML = '
No image loaded
'; - document.getElementById("diag-grid-bars").innerHTML = ""; - document.getElementById("diag-info").innerHTML = ""; - document.getElementById("diag-histogram").innerHTML = ""; - return; - } - let gridHtml = ""; - gridHtml += `
Detected size${info.gridSize ?? "none"}
`; - gridHtml += `
Confidence${info.gridConfidence != null ? (info.gridConfidence * 100).toFixed(1) + "%" : "n/a"}
`; - document.getElementById("diag-grid-info").innerHTML = gridHtml; - let barsHtml = ""; - if (info.gridScores && info.gridScores.length > 0) { - const maxScore = Math.max(...info.gridScores.map((s) => s[1])); - const bestSize = info.gridSize; - for (const [size, score] of info.gridScores) { - const pct = maxScore > 0 ? score / maxScore * 100 : 0; - const best = size === bestSize ? " best" : ""; - barsHtml += `
`; - barsHtml += `${size}`; - barsHtml += `
`; - barsHtml += `${score.toFixed(3)}`; - barsHtml += `
`; - } - } - document.getElementById("diag-grid-bars").innerHTML = barsHtml; - let infoHtml = ""; - infoHtml += `
Dimensions${info.width} x ${info.height}
`; - infoHtml += `
Unique colors${info.uniqueColors}
`; - document.getElementById("diag-info").innerHTML = infoHtml; - let histHtml = ""; - if (info.histogram) { - for (const entry of info.histogram) { - histHtml += `
`; - histHtml += `
`; - histHtml += `${entry.hex}`; - histHtml += `
`; - histHtml += `${entry.percent.toFixed(1)}%`; - histHtml += `
`; - } - } - document.getElementById("diag-histogram").innerHTML = histHtml; -} -async function loadImageBlob(which) { - const bytes = await invoke("get_image", { which }); - const arr = new Uint8Array(bytes); - const blob = new Blob([arr], { type: "image/png" }); - return URL.createObjectURL(blob); -} -async function openImage(path) { - setStatus("Loading image...", "processing"); - const wasOnWelcome = document.getElementById("welcome").style.display !== "none"; - if (wasOnWelcome) { - showWelcomeLoading(); - } - try { - const info = await invoke("open_image", { path }); - state.imageLoaded = true; - state.imagePath = path; - state.imageInfo = info; - state.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); - state.lospecResult = null; - state.lospecError = null; - state.paletteColors = null; - const fname = path.split("/").pop().split("\\").pop(); - document.getElementById("filename").textContent = fname; - hideWelcomeLoading(); - document.getElementById("welcome").style.display = "none"; - document.getElementById("original-pane").style.display = "flex"; - document.getElementById("processed-pane").style.display = "flex"; - const [origUrl, procUrl] = await Promise.all([ - loadImageBlob("original"), - loadImageBlob("processed") - ]); - document.getElementById("original-img").src = origUrl; - document.getElementById("processed-img").src = procUrl; - document.getElementById("original-dims").textContent = `${info.width}\xD7${info.height}`; - document.getElementById("processed-dims").textContent = `${info.width}\xD7${info.height}`; - document.getElementById("settings-preview-img").src = procUrl; - document.getElementById("settings-preview-img").style.display = "block"; - document.getElementById("settings-no-image").style.display = "none"; - renderSettings(); - renderDiagnostics(); - setStatus(`Loaded \u2014 ${info.width}\xD7${info.height}, grid=${info.gridSize ?? "none"}, ${info.uniqueColors} colors`, "success"); - } catch (e) { - hideWelcomeLoading(); - setStatus("Error: " + e, "error"); - } -} -function buildProcessConfig() { - const c = state.config; - return { - gridSize: c.gridSize, - gridPhaseX: c.gridPhaseX, - gridPhaseY: c.gridPhaseY, - maxGridCandidate: c.maxGridCandidate === 32 ? null : c.maxGridCandidate, - noGridDetect: c.noGridDetect, - downscaleMode: c.downscaleMode, - aaThreshold: c.aaThreshold, - paletteName: c.paletteName, - autoColors: c.autoColors, - customPalette: c.customPalette, - noQuantize: c.noQuantize, - removeBg: c.removeBg, - bgColor: c.bgColor, - borderThreshold: c.borderThreshold, - bgTolerance: c.bgTolerance, - floodFill: c.floodFill, - outputScale: c.outputScale, - outputWidth: c.outputWidth, - outputHeight: c.outputHeight - }; -} -async function processImage() { - if (!state.imageLoaded || state.processing) - return; - state.processing = true; - setStatus("Processing...", "processing"); - const t0 = performance.now(); - try { - const result = await invoke("process", { pc: buildProcessConfig() }); - state.imageInfo = { ...state.imageInfo, ...result }; - const procUrl = await loadImageBlob("processed"); - document.getElementById("processed-img").src = procUrl; - document.getElementById("processed-dims").textContent = `${result.width}\xD7${result.height}`; - document.getElementById("settings-preview-img").src = procUrl; - renderDiagnostics(); - const elapsed = ((performance.now() - t0) / 1000).toFixed(2); - state.lastProcessTime = performance.now() - t0; - setStatus(`Processed \u2014 ${result.width}\xD7${result.height}, ${result.uniqueColors} colors (${elapsed}s)`, "success"); - } catch (e) { - setStatus("Error: " + e, "error"); - } finally { - state.processing = false; - } -} -async function doOpen() { - try { - const result = await openDialog({ - multiple: false, - filters: [{ - name: "Images", - extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp"] - }] - }); - if (result) { - await openImage(result); - } - } catch (e) { - setStatus("Error: " + e, "error"); - } -} -async function doSave() { - if (!state.imageLoaded) - return; - try { - const result = await saveDialog({ - defaultPath: state.imagePath ? state.imagePath.replace(/\.[^.]+$/, "_fixed.png") : "output.png", - filters: [{ - name: "PNG Image", - extensions: ["png"] - }] - }); - if (result) { - await invoke("save_image", { path: result }); - setStatus("Saved: " + result.split("/").pop().split("\\").pop(), "success"); - } - } catch (e) { - setStatus("Error: " + e, "error"); - } -} -document.addEventListener("keydown", (e) => { - if (e.target.classList?.contains("setting-inline-input")) { - if (e.key === "Enter") { - e.preventDefault(); - const target = e.target; - commitEdit(target.dataset.key, target.value); - target.blur(); - } else if (e.key === "Escape") { - e.preventDefault(); - e.target.blur(); - renderSettings(); - } else if (e.key === "Tab") { - e.preventDefault(); - e.target.blur(); - cycleTab(e.shiftKey ? -1 : 1); - } - return; - } - if (e.target.classList?.contains("setting-inline-select")) { - if (e.key === "Tab") { - e.preventDefault(); - cycleTab(e.shiftKey ? -1 : 1); - } - return; - } - const tag = e.target.tagName; - if (tag === "INPUT" || tag === "TEXTAREA") { - if (e.key === "Tab") { - e.preventDefault(); - cycleTab(e.shiftKey ? -1 : 1); - } - return; - } - const key = e.key; - if (key === "Tab") { - e.preventDefault(); - cycleTab(e.shiftKey ? -1 : 1); - return; - } - if (key === "o") { - doOpen(); - return; - } - if (key === "s") { - doSave(); - return; - } - if (key === " ") { - e.preventDefault(); - processImage(); - return; - } - if (key === "r") { - resetConfig(); - return; - } - if ((e.ctrlKey || e.metaKey) && key === "q") { - window.close(); - return; - } - if (state.activeTab === "settings" && !state.processing) { - const rows = getSettingRows(); - if (key === "j" || key === "ArrowDown") { - e.preventDefault(); - state.settingsFocusIndex = Math.min(state.settingsFocusIndex + 1, rows.length - 1); - renderSettings(); - return; - } - if (key === "k" || key === "ArrowUp") { - e.preventDefault(); - state.settingsFocusIndex = Math.max(state.settingsFocusIndex - 1, 0); - renderSettings(); - return; - } - if (key === "Enter") { - e.preventDefault(); - const row = rows[state.settingsFocusIndex]; - if (row) - startEditing(row.key); - return; - } - if (key === "Escape") { - e.preventDefault(); - switchTab("preview"); - return; - } - if (key === "l" || key === "ArrowRight") { - e.preventDefault(); - const row = rows[state.settingsFocusIndex]; - if (row) { - adjustSetting(row.key, 1); - renderSettings(); - autoProcess(); - } - return; - } - if (key === "h" || key === "ArrowLeft") { - e.preventDefault(); - const row = rows[state.settingsFocusIndex]; - if (row) { - adjustSetting(row.key, -1); - renderSettings(); - autoProcess(); - } - return; - } - } -}); -var TABS = ["preview", "settings", "diagnostics", "batch", "sheet"]; -function cycleTab(dir) { - let idx = TABS.indexOf(state.activeTab); - idx = (idx + dir + TABS.length) % TABS.length; - switchTab(TABS[idx]); -} -function resetConfig() { - state.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); - state.lospecResult = null; - state.lospecError = null; - state.paletteColors = null; - renderSettings(); - if (state.imageLoaded) { - autoProcess(); - } - setStatus("Config reset to defaults"); -} -var processTimer = null; -function autoProcess() { - if (!state.imageLoaded) - return; - if (processTimer) - clearTimeout(processTimer); - processTimer = setTimeout(() => processImage(), 150); -} -function renderBatch() { - const el = document.getElementById("batch-content"); - let html = ""; - html += '
'; - html += '
Batch Processing
'; - html += '
Process multiple images with the current pipeline settings.
'; - html += "
"; - html += '
'; - html += `
Files${state.batchFiles.length} selected`; - html += ``; - if (state.batchFiles.length > 0) { - html += ``; - } - html += "
"; - if (state.batchFiles.length > 0) { - html += '
'; - for (const f of state.batchFiles) { - const name = f.split("/").pop().split("\\").pop(); - html += `
${escapeHtml(name)}
`; - } - html += "
"; - } - html += "
"; - html += '
'; - html += `
Output${state.batchOutputDir ? escapeHtml(state.batchOutputDir.split("/").pop().split("\\").pop()) : "not set"}`; - html += ``; - html += "
"; - html += "
"; - const canRun = state.batchFiles.length > 0 && state.batchOutputDir && !state.batchRunning; - html += '
'; - html += ``; - html += "
"; - if (state.batchProgress) { - const pct = Math.round(state.batchProgress.current / state.batchProgress.total * 100); - html += '
'; - html += `
${state.batchProgress.current}/${state.batchProgress.total} — ${escapeHtml(state.batchProgress.filename)}
`; - html += `
`; - html += "
"; - } - if (state.batchResult) { - const r = state.batchResult; - html += '
'; - html += `
${r.succeeded} succeeded`; - if (r.failed.length > 0) { - html += `, ${r.failed.length} failed`; - } - html += "
"; - if (r.failed.length > 0) { - html += '
'; - for (const f of r.failed) { - const name = f.path.split("/").pop().split("\\").pop(); - html += `
${escapeHtml(name)}: ${escapeHtml(f.error)}
`; - } - html += "
"; - } - html += "
"; - } - el.innerHTML = html; -} -async function batchAddFiles() { - try { - const result = await openDialog({ - multiple: true, - filters: [{ - name: "Images", - extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp"] - }] - }); - if (result) { - const paths = Array.isArray(result) ? result : [result]; - const existing = new Set(state.batchFiles); - for (const p of paths) { - if (p && !existing.has(p)) { - state.batchFiles.push(p); - existing.add(p); - } - } - renderBatch(); - } - } catch (e) { - setStatus("Error: " + e, "error"); - } -} -async function batchChooseDir() { - try { - const result = await openDialog({ - directory: true - }); - if (result) { - state.batchOutputDir = Array.isArray(result) ? result[0] : result; - renderBatch(); - } - } catch (e) { - setStatus("Error: " + e, "error"); - } -} -async function batchRun() { - if (state.batchRunning || state.batchFiles.length === 0 || !state.batchOutputDir) - return; - state.batchRunning = true; - state.batchResult = null; - state.batchProgress = { current: 0, total: state.batchFiles.length, filename: "" }; - renderBatch(); - setStatus("Batch processing...", "processing"); - const unlisten = await window.__TAURI__.event.listen("batch-progress", (event) => { - state.batchProgress = event.payload; - renderBatch(); - }); - try { - const result = await invoke("batch_process", { - inputPaths: state.batchFiles, - outputDir: state.batchOutputDir, - pc: buildProcessConfig(), - overwrite: false - }); - state.batchResult = result; - setStatus(`Batch done: ${result.succeeded} succeeded, ${result.failed.length} failed`, result.failed.length > 0 ? "error" : "success"); - } catch (e) { - setStatus("Batch error: " + e, "error"); - } finally { - state.batchRunning = false; - state.batchProgress = null; - if (typeof unlisten === "function") - unlisten(); - renderBatch(); - } -} -function renderSheet() { - const el = document.getElementById("sheet-content"); - const sc = state.sheetConfig; - const dis = state.sheetProcessing ? " disabled" : ""; - let html = ""; - html += '
'; - html += '
Sprite Sheet Processing
'; - html += '
Split a sprite sheet into individual tiles, run the normalize pipeline on each one, then reassemble into a clean sheet. You can also export each tile as a separate file or generate an animated GIF.
'; - if (!state.imageLoaded) { - html += '
Load an image first in the Preview tab.
'; - } - html += "
"; - html += '
'; - html += '
Split Mode
'; - html += '
'; - html += ``; - html += ``; - html += "
"; - if (state.sheetMode === "fixed") { - html += '
Use when your sheet has a uniform grid — all tiles are the same size with consistent spacing.
'; - } else { - html += '
Use when tiles are different sizes or irregularly placed. Detects sprites automatically by finding separator rows/columns. Sprites must be on a pure white background.
'; - } - html += "
"; - html += '
'; - if (state.sheetMode === "fixed") { - html += '
Tile Width'; - html += `
`; - html += '
Width of each tile in pixels. Required.
'; - html += '
Tile Height'; - html += `
`; - html += '
Height of each tile in pixels. Required.
'; - html += '
Spacing'; - html += `
`; - html += '
Gap between tiles in pixels. Set to 0 if tiles are packed edge-to-edge.
'; - html += '
Margin'; - html += `
`; - html += '
Border around the entire sheet in pixels. Usually 0.
'; - } else { - html += '
Sep. Threshold'; - html += `
`; - html += '
How uniform a row/column must be to count as a separator (0–1). Higher = stricter. 0.90 works for most sheets.
'; - html += '
Min Sprite Size'; - html += `
`; - html += '
Ignore detected regions smaller than this many pixels. Filters out noise and tiny fragments.
'; - html += '
Padding'; - html += `
`; - html += '
Extra pixels to include around each detected sprite. Useful if auto-detection crops too tightly.
'; - } - html += "
"; - html += '
'; - html += '
Skip Normalize'; - html += `
`; - html += '
When on, tiles are split and reassembled without running the pipeline. Useful for just extracting or rearranging tiles.
'; - html += "
"; - const canAct = state.imageLoaded && !state.sheetProcessing; - html += '
'; - html += '
'; - html += ``; - html += ``; - html += ``; - html += "
"; - html += '
Preview Split shows how many tiles will be extracted. Process Sheet runs the normalize pipeline on each tile and reassembles. Save Tiles exports each tile as a separate PNG.
'; - html += "
"; - if (state.sheetPreview) { - const p = state.sheetPreview; - html += '
'; - html += `
${p.tileCount} tiles — ${p.cols}\xD7${p.rows} grid — ${p.tileWidth}\xD7${p.tileHeight}px each
`; - html += "
"; - const gifDis = state.gifGenerating ? " disabled" : ""; - html += '
'; - html += '
GIF Animation
'; - html += '
Generate an animated GIF from the processed tiles. Preview it here or export to a file.
'; - html += '
Animate'; - html += '
'; - html += ``; - html += ``; - html += "
"; - if (state.gifMode === "row") { - html += '
Row'; - html += `
`; - html += `
Which row to animate (0\u2013${p.rows - 1}). Each row becomes one animation sequence.
`; - } - html += '
Frame Rate'; - html += `
`; - html += '
Frames per second (1\u2013100). 10 fps is a good default for pixel art animations.
'; - html += '
'; - html += ``; - html += ``; - html += "
"; - if (state.gifGenerating) { - html += '
Generating GIF...
'; - } - if (state.gifPreviewUrl) { - html += '
'; - html += `GIF Preview`; - html += "
"; - } - html += "
"; - } - if (state.sheetProcessing) { - html += '
Processing...
'; - } - el.innerHTML = html; -} -function readSheetConfig() { - const sc = state.sheetConfig; - if (state.sheetMode === "fixed") { - const tw = document.getElementById("sheet-tw"); - const th = document.getElementById("sheet-th"); - const sp = document.getElementById("sheet-sp"); - const mg = document.getElementById("sheet-mg"); - if (tw) { - const v = parseInt(tw.value); - sc.tileWidth = isNaN(v) || v < 1 ? null : v; - } - if (th) { - const v = parseInt(th.value); - sc.tileHeight = isNaN(v) || v < 1 ? null : v; - } - if (sp) { - const v = parseInt(sp.value); - sc.spacing = isNaN(v) ? 0 : Math.max(0, v); - } - if (mg) { - const v = parseInt(mg.value); - sc.margin = isNaN(v) ? 0 : Math.max(0, v); - } - } else { - const sep = document.getElementById("sheet-sep"); - const min = document.getElementById("sheet-min"); - const pad = document.getElementById("sheet-pad"); - if (sep) { - const v = parseFloat(sep.value); - sc.separatorThreshold = isNaN(v) ? 0.9 : Math.max(0, Math.min(1, v)); - } - if (min) { - const v = parseInt(min.value); - sc.minSpriteSize = isNaN(v) ? 8 : Math.max(1, v); - } - if (pad) { - const v = parseInt(pad.value); - sc.pad = isNaN(v) ? 0 : Math.max(0, v); - } - } -} -function buildSheetArgs() { - const sc = state.sheetConfig; - return { - mode: state.sheetMode, - tileWidth: sc.tileWidth, - tileHeight: sc.tileHeight, - spacing: sc.spacing, - margin: sc.margin, - separatorThreshold: sc.separatorThreshold, - minSpriteSize: sc.minSpriteSize, - pad: sc.pad, - noNormalize: sc.noNormalize || null - }; -} -async function sheetPreviewAction() { - if (!state.imageLoaded || state.sheetProcessing) - return; - readSheetConfig(); - state.sheetProcessing = true; - renderSheet(); - try { - const result = await invoke("sheet_preview", buildSheetArgs()); - state.sheetPreview = result; - setStatus(`Sheet: ${result.tileCount} tiles (${result.cols}\xD7${result.rows})`, "success"); - } catch (e) { - setStatus("Sheet error: " + e, "error"); - state.sheetPreview = null; - } finally { - state.sheetProcessing = false; - renderSheet(); - } -} -async function sheetProcessAction() { - if (!state.imageLoaded || state.sheetProcessing) - return; - readSheetConfig(); - state.sheetProcessing = true; - state.gifPreviewUrl = null; - renderSheet(); - setStatus("Processing sheet...", "processing"); - const t0 = performance.now(); - try { - const args = { ...buildSheetArgs(), pc: buildProcessConfig() }; - const result = await invoke("sheet_process", args); - state.sheetPreview = result; - const procUrl = await loadImageBlob("processed"); - document.getElementById("processed-img").src = procUrl; - document.getElementById("processed-dims").textContent = `${result.outputWidth}\xD7${result.outputHeight}`; - document.getElementById("settings-preview-img").src = procUrl; - const elapsed = ((performance.now() - t0) / 1000).toFixed(2); - setStatus(`Sheet processed: ${result.tileCount} tiles, ${result.outputWidth}\xD7${result.outputHeight} (${elapsed}s)`, "success"); - } catch (e) { - setStatus("Sheet error: " + e, "error"); - } finally { - state.sheetProcessing = false; - renderSheet(); - } -} -async function sheetSaveTilesAction() { - try { - const result = await openDialog({ directory: true }); - if (result) { - const dir = Array.isArray(result) ? result[0] : result; - const count = await invoke("sheet_save_tiles", { outputDir: dir }); - setStatus(`Saved ${count} tiles to ${dir.split("/").pop().split("\\").pop()}`, "success"); - } - } catch (e) { - setStatus("Error saving tiles: " + e, "error"); - } -} -function readGifConfig() { - const rowEl = document.getElementById("gif-row"); - const fpsEl = document.getElementById("gif-fps"); - if (rowEl) { - const v = parseInt(rowEl.value); - state.gifRow = isNaN(v) ? 0 : Math.max(0, v); - } - if (fpsEl) { - const v = parseInt(fpsEl.value); - state.gifFps = isNaN(v) ? 10 : Math.max(1, Math.min(100, v)); - } -} -async function gifPreviewAction() { - if (state.gifGenerating) - return; - readGifConfig(); - state.gifGenerating = true; - state.gifPreviewUrl = null; - renderSheet(); - setStatus("Generating GIF preview...", "processing"); - try { - const dataUrl = await invoke("sheet_generate_gif", { - mode: state.gifMode, - row: state.gifMode === "row" ? state.gifRow : null, - fps: state.gifFps - }); - state.gifPreviewUrl = dataUrl; - setStatus("GIF preview generated", "success"); - } catch (e) { - setStatus("GIF error: " + e, "error"); - } finally { - state.gifGenerating = false; - renderSheet(); - } -} -async function gifExportAction() { - if (!state.gifPreviewUrl) - return; - readGifConfig(); - try { - const defaultName = state.gifMode === "row" ? `row_${state.gifRow}.gif` : "animation.gif"; - const path = await saveDialog({ - filters: [{ name: "GIF", extensions: ["gif"] }], - defaultPath: defaultName - }); - if (path) { - setStatus("Exporting GIF...", "processing"); - await invoke("sheet_export_gif", { - path, - mode: state.gifMode, - row: state.gifMode === "row" ? state.gifRow : null, - fps: state.gifFps - }); - const fname = path.split("/").pop().split("\\").pop(); - setStatus(`GIF saved to ${fname}`, "success"); - } - } catch (e) { - setStatus("GIF export error: " + e, "error"); - } -} -document.querySelector(".tab-bar").addEventListener("click", (e) => { - const tab = e.target.closest(".tab"); - if (tab) - switchTab(tab.dataset.tab); -}); -var dropOverlay = document.getElementById("drop-overlay"); -var dragCounter = 0; -document.addEventListener("dragenter", (e) => { - e.preventDefault(); - dragCounter++; - dropOverlay.classList.add("active"); -}); -document.addEventListener("dragleave", (e) => { - e.preventDefault(); - dragCounter--; - if (dragCounter <= 0) { - dragCounter = 0; - dropOverlay.classList.remove("active"); - } -}); -document.addEventListener("dragover", (e) => { - e.preventDefault(); -}); -document.addEventListener("drop", async (e) => { - e.preventDefault(); - dragCounter = 0; - dropOverlay.classList.remove("active"); - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const file = files[0]; - if (file.path) { - await openImage(file.path); - } - } -}); -if (window.__TAURI__?.event) { - window.__TAURI__.event.listen("tauri://drag-drop", async (event) => { - dropOverlay.classList.remove("active"); - dragCounter = 0; - const paths = event.payload?.paths; - if (paths && paths.length > 0) { - await openImage(paths[0]); - } - }); - window.__TAURI__.event.listen("tauri://drag-enter", () => { - dropOverlay.classList.add("active"); - }); - window.__TAURI__.event.listen("tauri://drag-leave", () => { - dropOverlay.classList.remove("active"); - dragCounter = 0; - }); -} -document.getElementById("settings-list").addEventListener("click", (e) => { - const target = e.target; - if (target.classList?.contains("setting-clear") && !state.processing) { - skipNextBlurCommit = true; - const key = target.dataset.key; - clearSetting(key); - return; - } - if (target.classList?.contains("setting-toggle") && !state.processing) { - const key = target.dataset.key; - const row2 = target.closest(".setting-row"); - if (row2) - state.settingsFocusIndex = parseInt(row2.dataset.index); - if (BOOLEAN_SETTINGS.includes(key)) { - adjustSetting(key, 1); - renderSettings(); - autoProcess(); - } else { - startEditing(key); - } - return; - } - const row = target.closest(".setting-row"); - if (row) { - state.settingsFocusIndex = parseInt(row.dataset.index); - renderSettings(); - } -}); -var skipNextBlurCommit = false; -document.getElementById("settings-list").addEventListener("focusout", (e) => { - const target = e.target; - if (target.classList?.contains("setting-inline-input")) { - setTimeout(() => { - if (skipNextBlurCommit) { - skipNextBlurCommit = false; - return; - } - commitEdit(target.dataset.key, target.value); - }, 50); - } -}); -document.getElementById("settings-list").addEventListener("change", (e) => { - const target = e.target; - if (target.tagName === "SELECT" && target.classList?.contains("setting-inline-select")) { - commitEdit(target.dataset.key, target.value); - } -}); -async function init() { - try { - state.palettes = await invoke("list_palettes"); - } catch (e) { - console.error("Failed to load palettes:", e); - } - renderSettings(); - renderDiagnostics(); - renderBatch(); - renderSheet(); -} -document.getElementById("batch-content").addEventListener("click", (e) => { - const target = e.target; - if (target.id === "batch-add-files") { - batchAddFiles(); - return; - } - if (target.id === "batch-clear-files") { - state.batchFiles = []; - state.batchResult = null; - renderBatch(); - return; - } - if (target.id === "batch-choose-dir") { - batchChooseDir(); - return; - } - if (target.id === "batch-run") { - batchRun(); - return; - } -}); -document.getElementById("sheet-content").addEventListener("click", (e) => { - const target = e.target; - if (target.classList?.contains("sheet-mode-btn") && !target.classList.contains("gif-mode-btn")) { - const mode = target.dataset.mode; - if (mode) { - state.sheetMode = mode; - state.sheetPreview = null; - renderSheet(); - } - return; - } - if (target.classList?.contains("gif-mode-btn")) { - const gifMode = target.dataset.gifMode; - if (gifMode) { - state.gifMode = gifMode; - state.gifPreviewUrl = null; - renderSheet(); - } - return; - } - if (target.id === "sheet-no-normalize") { - state.sheetConfig.noNormalize = !state.sheetConfig.noNormalize; - renderSheet(); - return; - } - if (target.id === "sheet-preview-btn") { - sheetPreviewAction(); - return; - } - if (target.id === "sheet-process-btn") { - sheetProcessAction(); - return; - } - if (target.id === "sheet-save-tiles-btn") { - sheetSaveTilesAction(); - return; - } - if (target.id === "gif-preview-btn") { - gifPreviewAction(); - return; - } - if (target.id === "gif-export-btn") { - gifExportAction(); - return; - } -}); -init(); - -//# debugId=8D39E10E5EF8A03C64756E2164756E21 -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGl4Zml4L3VpL3NyYy9hcHAudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbCiAgICAiLy8gcGl4Zml4IOKAlCBhcHBsaWNhdGlvbiBsb2dpYyAoVHlwZVNjcmlwdClcbi8vIFVzZXMgd2luZG93Ll9fVEFVUklfXyAod2l0aEdsb2JhbFRhdXJpOiB0cnVlIGluIHRhdXJpLmNvbmYuanNvbilcblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBUYXVyaSBBUEkgYmluZGluZ3Ncbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuXG5kZWNsYXJlIGdsb2JhbCB7XG4gIGludGVyZmFjZSBXaW5kb3cge1xuICAgIF9fVEFVUklfXzoge1xuICAgICAgY29yZToge1xuICAgICAgICBpbnZva2U6IDxUID0gdW5rbm93bj4oY21kOiBzdHJpbmcsIGFyZ3M/OiBSZWNvcmQ8c3RyaW5nLCB1bmtub3duPikgPT4gUHJvbWlzZTxUPjtcbiAgICAgIH07XG4gICAgICBkaWFsb2c6IHtcbiAgICAgICAgb3BlbjogKG9wdGlvbnM/OiBEaWFsb2dPcHRpb25zKSA9PiBQcm9taXNlPHN0cmluZyB8IG51bGw+O1xuICAgICAgICBzYXZlOiAob3B0aW9ucz86IERpYWxvZ09wdGlvbnMpID0+IFByb21pc2U8c3RyaW5nIHwgbnVsbD47XG4gICAgICB9O1xuICAgICAgZXZlbnQ6IHtcbiAgICAgICAgbGlzdGVuOiAoZXZlbnQ6IHN0cmluZywgaGFuZGxlcjogKGV2ZW50OiBUYXVyaUV2ZW50KSA9PiB2b2lkKSA9PiBQcm9taXNlPHZvaWQ+O1xuICAgICAgfTtcbiAgICB9O1xuICB9XG59XG5cbmludGVyZmFjZSBEaWFsb2dPcHRpb25zIHtcbiAgbXVsdGlwbGU/OiBib29sZWFuO1xuICBkaXJlY3Rvcnk/OiBib29sZWFuO1xuICBkZWZhdWx0UGF0aD86IHN0cmluZztcbiAgZmlsdGVycz86IHsgbmFtZTogc3RyaW5nOyBleHRlbnNpb25zOiBzdHJpbmdbXSB9W107XG59XG5cbmludGVyZmFjZSBUYXVyaUV2ZW50IHtcbiAgcGF5bG9hZD86IHsgcGF0aHM/OiBzdHJpbmdbXSB9O1xufVxuXG5jb25zdCB7IGludm9rZSB9ID0gd2luZG93Ll9fVEFVUklfXy5jb3JlO1xuY29uc3QgeyBvcGVuOiBvcGVuRGlhbG9nLCBzYXZlOiBzYXZlRGlhbG9nIH0gPSB3aW5kb3cuX19UQVVSSV9fLmRpYWxvZztcblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBCYWNrZW5kIHR5cGVzIChtaXJyb3IgUnVzdCBzZXJkZSBzdHJ1Y3RzKVxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmludGVyZmFjZSBJbWFnZUluZm8ge1xuICB3aWR0aDogbnVtYmVyO1xuICBoZWlnaHQ6IG51bWJlcjtcbiAgZ3JpZFNpemU6IG51bWJlciB8IG51bGw7XG4gIGdyaWRDb25maWRlbmNlOiBudW1iZXIgfCBudWxsO1xuICB1bmlxdWVDb2xvcnM6IG51bWJlcjtcbiAgZ3JpZFNjb3JlczogW251bWJlciwgbnVtYmVyXVtdO1xuICBoaXN0b2dyYW06IENvbG9yRW50cnlbXTtcbn1cblxuaW50ZXJmYWNlIFByb2Nlc3NSZXN1bHQge1xuICB3aWR0aDogbnVtYmVyO1xuICBoZWlnaHQ6IG51bWJlcjtcbiAgZ3JpZFNpemU6IG51bWJlciB8IG51bGw7XG4gIGdyaWRDb25maWRlbmNlOiBudW1iZXIgfCBudWxsO1xuICB1bmlxdWVDb2xvcnM6IG51bWJlcjtcbiAgZ3JpZFNjb3JlczogW251bWJlciwgbnVtYmVyXVtdO1xuICBoaXN0b2dyYW06IENvbG9yRW50cnlbXTtcbn1cblxuaW50ZXJmYWNlIENvbG9yRW50cnkge1xuICBoZXg6IHN0cmluZztcbiAgcjogbnVtYmVyO1xuICBnOiBudW1iZXI7XG4gIGI6IG51bWJlcjtcbiAgcGVyY2VudDogbnVtYmVyO1xufVxuXG5pbnRlcmZhY2UgUGFsZXR0ZUluZm8ge1xuICBuYW1lOiBzdHJpbmc7XG4gIHNsdWc6IHN0cmluZztcbiAgbnVtQ29sb3JzOiBudW1iZXI7XG59XG5cbmludGVyZmFjZSBMb3NwZWNSZXN1bHQge1xuICBuYW1lOiBzdHJpbmc7XG4gIHNsdWc6IHN0cmluZztcbiAgbnVtQ29sb3JzOiBudW1iZXI7XG4gIGNvbG9yczogc3RyaW5nW107XG59XG5cbmludGVyZmFjZSBQcm9jZXNzQ29uZmlnIHtcbiAgZ3JpZFNpemU6IG51bWJlciB8IG51bGw7XG4gIGdyaWRQaGFzZVg6IG51bWJlciB8IG51bGw7XG4gIGdyaWRQaGFzZVk6IG51bWJlciB8IG51bGw7XG4gIG1heEdyaWRDYW5kaWRhdGU6IG51bWJlciB8IG51bGw7XG4gIG5vR3JpZERldGVjdDogYm9vbGVhbjtcbiAgZG93bnNjYWxlTW9kZTogc3RyaW5nO1xuICBhYVRocmVzaG9sZDogbnVtYmVyIHwgbnVsbDtcbiAgcGFsZXR0ZU5hbWU6IHN0cmluZyB8IG51bGw7XG4gIGF1dG9Db2xvcnM6IG51bWJlciB8IG51bGw7XG4gIGN1c3RvbVBhbGV0dGU6IHN0cmluZ1tdIHwgbnVsbDtcbiAgcmVtb3ZlQmc6IGJvb2xlYW47XG4gIG5vUXVhbnRpemU6IGJvb2xlYW47XG4gIGJnQ29sb3I6IHN0cmluZyB8IG51bGw7XG4gIGJvcmRlclRocmVzaG9sZDogbnVtYmVyIHwgbnVsbDtcbiAgYmdUb2xlcmFuY2U6IG51bWJlcjtcbiAgZmxvb2RGaWxsOiBib29sZWFuO1xuICBvdXRwdXRTY2FsZTogbnVtYmVyIHwgbnVsbDtcbiAgb3V0cHV0V2lkdGg6IG51bWJlciB8IG51bGw7XG4gIG91dHB1dEhlaWdodDogbnVtYmVyIHwgbnVsbDtcbn1cblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBTdGF0ZVxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmludGVyZmFjZSBBcHBDb25maWcge1xuICBncmlkU2l6ZTogbnVtYmVyIHwgbnVsbDtcbiAgZ3JpZFBoYXNlWDogbnVtYmVyIHwgbnVsbDtcbiAgZ3JpZFBoYXNlWTogbnVtYmVyIHwgbnVsbDtcbiAgbWF4R3JpZENhbmRpZGF0ZTogbnVtYmVyO1xuICBub0dyaWREZXRlY3Q6IGJvb2xlYW47XG4gIGRvd25zY2FsZU1vZGU6IHN0cmluZztcbiAgYWFUaHJlc2hvbGQ6IG51bWJlciB8IG51bGw7XG4gIHBhbGV0dGVOYW1lOiBzdHJpbmcgfCBudWxsO1xuICBhdXRvQ29sb3JzOiBudW1iZXIgfCBudWxsO1xuICBsb3NwZWNTbHVnOiBzdHJpbmcgfCBudWxsO1xuICBjdXN0b21QYWxldHRlOiBzdHJpbmdbXSB8IG51bGw7XG4gIG5vUXVhbnRpemU6IGJvb2xlYW47XG4gIHJlbW92ZUJnOiBib29sZWFuO1xuICBiZ0NvbG9yOiBzdHJpbmcgfCBudWxsO1xuICBib3JkZXJUaHJlc2hvbGQ6IG51bWJlciB8IG51bGw7XG4gIGJnVG9sZXJhbmNlOiBudW1iZXI7XG4gIGZsb29kRmlsbDogYm9vbGVhbjtcbiAgb3V0cHV0U2NhbGU6IG51bWJlciB8IG51bGw7XG4gIG91dHB1dFdpZHRoOiBudW1iZXIgfCBudWxsO1xuICBvdXRwdXRIZWlnaHQ6IG51bWJlciB8IG51bGw7XG59XG5cbmludGVyZmFjZSBBcHBTdGF0ZSB7XG4gIGFjdGl2ZVRhYjogc3RyaW5nO1xuICBpbWFnZUxvYWRlZDogYm9vbGVhbjtcbiAgaW1hZ2VQYXRoOiBzdHJpbmcgfCBudWxsO1xuICBpbWFnZUluZm86IEltYWdlSW5mbyB8IG51bGw7XG4gIHNldHRpbmdzRm9jdXNJbmRleDogbnVtYmVyO1xuICBwcm9jZXNzaW5nOiBib29sZWFuO1xuICBwYWxldHRlczogUGFsZXR0ZUluZm9bXTtcbiAgcGFsZXR0ZUluZGV4OiBudW1iZXI7XG4gIGNvbmZpZzogQXBwQ29uZmlnO1xuICAvLyBMb3NwZWMgc3RhdGVcbiAgbG9zcGVjUmVzdWx0OiBMb3NwZWNSZXN1bHQgfCBudWxsO1xuICBsb3NwZWNFcnJvcjogc3RyaW5nIHwgbnVsbDtcbiAgbG9zcGVjTG9hZGluZzogYm9vbGVhbjtcbiAgLy8gUGFsZXR0ZSBzd2F0Y2hlcyBmb3IgY3VycmVudCBzZWxlY3Rpb25cbiAgcGFsZXR0ZUNvbG9yczogc3RyaW5nW10gfCBudWxsO1xuICAvLyBIZWxwIHZpc2liaWxpdHlcbiAgc2hvd0FsbEhlbHA6IGJvb2xlYW47XG4gIC8vIFRpbWluZ1xuICBsYXN0UHJvY2Vzc1RpbWU6IG51bWJlciB8IG51bGw7XG4gIC8vIEJhdGNoIHN0YXRlXG4gIGJhdGNoRmlsZXM6IHN0cmluZ1tdO1xuICBiYXRjaE91dHB1dERpcjogc3RyaW5nIHwgbnVsbDtcbiAgYmF0Y2hSdW5uaW5nOiBib29sZWFuO1xuICBiYXRjaFByb2dyZXNzOiB7IGN1cnJlbnQ6IG51bWJlcjsgdG90YWw6IG51bWJlcjsgZmlsZW5hbWU6IHN0cmluZyB9IHwgbnVsbDtcbiAgYmF0Y2hSZXN1bHQ6IHsgc3VjY2VlZGVkOiBudW1iZXI7IGZhaWxlZDogeyBwYXRoOiBzdHJpbmc7IGVycm9yOiBzdHJpbmcgfVtdIH0gfCBudWxsO1xuICAvLyBTaGVldCBzdGF0ZVxuICBzaGVldE1vZGU6ICdmaXhlZCcgfCAnYXV0byc7XG4gIHNoZWV0Q29uZmlnOiB7XG4gICAgdGlsZVdpZHRoOiBudW1iZXIgfCBudWxsO1xuICAgIHRpbGVIZWlnaHQ6IG51bWJlciB8IG51bGw7XG4gICAgc3BhY2luZzogbnVtYmVyO1xuICAgIG1hcmdpbjogbnVtYmVyO1xuICAgIHNlcGFyYXRvclRocmVzaG9sZDogbnVtYmVyO1xuICAgIG1pblNwcml0ZVNpemU6IG51bWJlcjtcbiAgICBwYWQ6IG51bWJlcjtcbiAgICBub05vcm1hbGl6ZTogYm9vbGVhbjtcbiAgfTtcbiAgc2hlZXRQcmV2aWV3OiB7IHRpbGVDb3VudDogbnVtYmVyOyB0aWxlV2lkdGg6IG51bWJlcjsgdGlsZUhlaWdodDogbnVtYmVyOyBjb2xzOiBudW1iZXI7IHJvd3M6IG51bWJlciB9IHwgbnVsbDtcbiAgc2hlZXRQcm9jZXNzaW5nOiBib29sZWFuO1xuICAvLyBHSUYgYW5pbWF0aW9uIHN0YXRlXG4gIGdpZk1vZGU6ICdyb3cnIHwgJ2FsbCc7XG4gIGdpZlJvdzogbnVtYmVyO1xuICBnaWZGcHM6IG51bWJlcjtcbiAgZ2lmUHJldmlld1VybDogc3RyaW5nIHwgbnVsbDtcbiAgZ2lmR2VuZXJhdGluZzogYm9vbGVhbjtcbn1cblxuY29uc3Qgc3RhdGU6IEFwcFN0YXRlID0ge1xuICBhY3RpdmVUYWI6ICdwcmV2aWV3JyxcbiAgaW1hZ2VMb2FkZWQ6IGZhbHNlLFxuICBpbWFnZVBhdGg6IG51bGwsXG4gIGltYWdlSW5mbzogbnVsbCxcbiAgc2V0dGluZ3NGb2N1c0luZGV4OiAwLFxuICBwcm9jZXNzaW5nOiBmYWxzZSxcbiAgcGFsZXR0ZXM6IFtdLFxuICBwYWxldHRlSW5kZXg6IDAsXG4gIGNvbmZpZzoge1xuICAgIGdyaWRTaXplOiBudWxsLFxuICAgIGdyaWRQaGFzZVg6IG51bGwsXG4gICAgZ3JpZFBoYXNlWTogbnVsbCxcbiAgICBtYXhHcmlkQ2FuZGlkYXRlOiAzMixcbiAgICBub0dyaWREZXRlY3Q6IGZhbHNlLFxuICAgIGRvd25zY2FsZU1vZGU6ICdzbmFwJyxcbiAgICBhYVRocmVzaG9sZDogbnVsbCxcbiAgICBwYWxldHRlTmFtZTogbnVsbCxcbiAgICBhdXRvQ29sb3JzOiBudWxsLFxuICAgIGxvc3BlY1NsdWc6IG51bGwsXG4gICAgY3VzdG9tUGFsZXR0ZTogbnVsbCxcbiAgICBub1F1YW50aXplOiBmYWxzZSxcbiAgICByZW1vdmVCZzogZmFsc2UsXG4gICAgYmdDb2xvcjogbnVsbCxcbiAgICBib3JkZXJUaHJlc2hvbGQ6IG51bGwsXG4gICAgYmdUb2xlcmFuY2U6IDAuMDUsXG4gICAgZmxvb2RGaWxsOiB0cnVlLFxuICAgIG91dHB1dFNjYWxlOiBudWxsLFxuICAgIG91dHB1dFdpZHRoOiBudWxsLFxuICAgIG91dHB1dEhlaWdodDogbnVsbCxcbiAgfSxcbiAgbG9zcGVjUmVzdWx0OiBudWxsLFxuICBsb3NwZWNFcnJvcjogbnVsbCxcbiAgbG9zcGVjTG9hZGluZzogZmFsc2UsXG4gIHBhbGV0dGVDb2xvcnM6IG51bGwsXG4gIHNob3dBbGxIZWxwOiBmYWxzZSxcbiAgbGFzdFByb2Nlc3NUaW1lOiBudWxsLFxuICAvLyBCYXRjaFxuICBiYXRjaEZpbGVzOiBbXSxcbiAgYmF0Y2hPdXRwdXREaXI6IG51bGwsXG4gIGJhdGNoUnVubmluZzogZmFsc2UsXG4gIGJhdGNoUHJvZ3Jlc3M6IG51bGwsXG4gIGJhdGNoUmVzdWx0OiBudWxsLFxuICAvLyBTaGVldFxuICBzaGVldE1vZGU6ICdhdXRvJyxcbiAgc2hlZXRDb25maWc6IHtcbiAgICB0aWxlV2lkdGg6IG51bGwsXG4gICAgdGlsZUhlaWdodDogbnVsbCxcbiAgICBzcGFjaW5nOiAwLFxuICAgIG1hcmdpbjogMCxcbiAgICBzZXBhcmF0b3JUaHJlc2hvbGQ6IDAuOTAsXG4gICAgbWluU3ByaXRlU2l6ZTogOCxcbiAgICBwYWQ6IDAsXG4gICAgbm9Ob3JtYWxpemU6IGZhbHNlLFxuICB9LFxuICBzaGVldFByZXZpZXc6IG51bGwsXG4gIHNoZWV0UHJvY2Vzc2luZzogZmFsc2UsXG4gIC8vIEdJRlxuICBnaWZNb2RlOiAncm93JyxcbiAgZ2lmUm93OiAwLFxuICBnaWZGcHM6IDEwLFxuICBnaWZQcmV2aWV3VXJsOiBudWxsLFxuICBnaWZHZW5lcmF0aW5nOiBmYWxzZSxcbn07XG5cbmNvbnN0IERFRkFVTFRfQ09ORklHOiBBcHBDb25maWcgPSBKU09OLnBhcnNlKEpTT04uc3RyaW5naWZ5KHN0YXRlLmNvbmZpZykpO1xuXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cbi8vIFNldHRpbmdzIGRlZmluaXRpb25zXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuY29uc3QgRE9XTlNDQUxFX01PREVTID0gWydzbmFwJywgJ2NlbnRlci13ZWlnaHRlZCcsICdtYWpvcml0eS12b3RlJywgJ2NlbnRlci1waXhlbCddO1xuXG5pbnRlcmZhY2UgU2V0dGluZ1NlY3Rpb24ge1xuICBzZWN0aW9uOiBzdHJpbmc7XG4gIGtleT86IHVuZGVmaW5lZDtcbn1cblxuaW50ZXJmYWNlIFNldHRpbmdSb3cge1xuICBzZWN0aW9uPzogdW5kZWZpbmVkO1xuICBrZXk6IHN0cmluZztcbiAgbGFiZWw6IHN0cmluZztcbiAgdmFsdWU6IHN0cmluZztcbiAgaGVscDogc3RyaW5nO1xuICBjaGFuZ2VkOiBib29sZWFuO1xufVxuXG50eXBlIFNldHRpbmdFbnRyeSA9IFNldHRpbmdTZWN0aW9uIHwgU2V0dGluZ1JvdztcblxuZnVuY3Rpb24gZ2V0U2V0dGluZ3MoKTogU2V0dGluZ0VudHJ5W10ge1xuICBjb25zdCBjID0gc3RhdGUuY29uZmlnO1xuICByZXR1cm4gW1xuICAgIHsgc2VjdGlvbjogJ0dyaWQgRGV0ZWN0aW9uJyB9LFxuICAgIHtcbiAgICAgIGtleTogJ2dyaWRTaXplJywgbGFiZWw6ICdHcmlkIFNpemUnLFxuICAgICAgdmFsdWU6IGMuZ3JpZFNpemUgPT09IG51bGwgPyAnYXV0bycgOiBTdHJpbmcoYy5ncmlkU2l6ZSksXG4gICAgICBoZWxwOiAnSG93IG1hbnkgc2NyZWVuIHBpeGVscyBtYWtlIHVwIG9uZSBcImxvZ2ljYWxcIiBwaXhlbCBpbiB5b3VyIGFydC4gQXV0by1kZXRlY3Rpb24gd29ya3Mgd2VsbCBmb3IgbW9zdCBpbWFnZXMuIE92ZXJyaWRlIGlmIHRoZSBncmlkIGxvb2tzIHdyb25nLicsXG4gICAgICBjaGFuZ2VkOiBjLmdyaWRTaXplICE9PSBudWxsLFxuICAgIH0sXG4gICAge1xuICAgICAga2V5OiAnZ3JpZFBoYXNlWCcsIGxhYmVsOiAnUGhhc2UgWCcsXG4gICAgICB2YWx1ZTogYy5ncmlkUGhhc2VYID09PSBudWxsID8gJ2F1dG8nIDogU3RyaW5nKGMuZ3JpZFBoYXNlWCksXG4gICAgICBoZWxwOiAnT3ZlcnJpZGUgdGhlIFggb2Zmc2V0IG9mIHRoZSBncmlkIGFsaWdubWVudC4gVXN1YWxseSBhdXRvLWRldGVjdGVkLicsXG4gICAgICBjaGFuZ2VkOiBjLmdyaWRQaGFzZVggIT09IG51bGwsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdncmlkUGhhc2VZJywgbGFiZWw6ICdQaGFzZSBZJyxcbiAgICAgIHZhbHVlOiBjLmdyaWRQaGFzZVkgPT09IG51bGwgPyAnYXV0bycgOiBTdHJpbmcoYy5ncmlkUGhhc2VZKSxcbiAgICAgIGhlbHA6ICdPdmVycmlkZSB0aGUgWSBvZmZzZXQgb2YgdGhlIGdyaWQgYWxpZ25tZW50LiBVc3VhbGx5IGF1dG8tZGV0ZWN0ZWQuJyxcbiAgICAgIGNoYW5nZWQ6IGMuZ3JpZFBoYXNlWSAhPT0gbnVsbCxcbiAgICB9LFxuICAgIHtcbiAgICAgIGtleTogJ25vR3JpZERldGVjdCcsIGxhYmVsOiAnU2tpcCBHcmlkJyxcbiAgICAgIHZhbHVlOiBjLm5vR3JpZERldGVjdCA/ICdvbicgOiAnb2ZmJyxcbiAgICAgIGhlbHA6ICdTa2lwIGdyaWQgZGV0ZWN0aW9uIGVudGlyZWx5LiBVc2VmdWwgaWYgeW91ciBpbWFnZSBpcyBhbHJlYWR5IGF0IGxvZ2ljYWwgcmVzb2x1dGlvbi4nLFxuICAgICAgY2hhbmdlZDogYy5ub0dyaWREZXRlY3QsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdtYXhHcmlkQ2FuZGlkYXRlJywgbGFiZWw6ICdNYXggR3JpZCcsXG4gICAgICB2YWx1ZTogU3RyaW5nKGMubWF4R3JpZENhbmRpZGF0ZSksXG4gICAgICBoZWxwOiAnTWF4aW11bSBncmlkIHNpemUgdG8gdGVzdCBkdXJpbmcgYXV0by1kZXRlY3Rpb24gKGRlZmF1bHQ6IDMyKS4nLFxuICAgICAgY2hhbmdlZDogYy5tYXhHcmlkQ2FuZGlkYXRlICE9PSAzMixcbiAgICB9LFxuICAgIHtcbiAgICAgIGtleTogJ2Rvd25zY2FsZU1vZGUnLCBsYWJlbDogJ01vZGUnLFxuICAgICAgdmFsdWU6IGMuZG93bnNjYWxlTW9kZSxcbiAgICAgIGhlbHA6ICdIb3cgdG8gY29tYmluZSBwaXhlbHMgaW4gZWFjaCBncmlkIGNlbGwuIFwic25hcFwiIGNsZWFucyBpbi1wbGFjZSBhdCBvcmlnaW5hbCByZXNvbHV0aW9uLiBPdGhlcnMgcmVkdWNlIHRvIGxvZ2ljYWwgcGl4ZWwgcmVzb2x1dGlvbi4nLFxuICAgICAgY2hhbmdlZDogYy5kb3duc2NhbGVNb2RlICE9PSAnc25hcCcsXG4gICAgfSxcbiAgICB7IHNlY3Rpb246ICdBbnRpLUFsaWFzaW5nJyB9LFxuICAgIHtcbiAgICAgIGtleTogJ2FhVGhyZXNob2xkJywgbGFiZWw6ICdBQSBSZW1vdmFsJyxcbiAgICAgIHZhbHVlOiBjLmFhVGhyZXNob2xkID09PSBudWxsID8gJ29mZicgOiBjLmFhVGhyZXNob2xkLnRvRml4ZWQoMiksXG4gICAgICBoZWxwOiAnUmVtb3ZlcyBzb2Z0IGJsZW5kaW5nIGJldHdlZW4gY29sb3JzIGFkZGVkIGJ5IEFJIGdlbmVyYXRvcnMuIExvd2VyIHZhbHVlcyBhcmUgbW9yZSBhZ2dyZXNzaXZlLiBUcnkgMC4zMFxcdTIwMTMwLjUwIGZvciBtb3N0IGltYWdlcy4nLFxuICAgICAgY2hhbmdlZDogYy5hYVRocmVzaG9sZCAhPT0gbnVsbCxcbiAgICB9LFxuICAgIHsgc2VjdGlvbjogJ0NvbG9yIFBhbGV0dGUnIH0sXG4gICAge1xuICAgICAga2V5OiAncGFsZXR0ZU5hbWUnLCBsYWJlbDogJ1BhbGV0dGUnLFxuICAgICAgdmFsdWU6IGMucGFsZXR0ZU5hbWUgPT09IG51bGwgPyAnbm9uZScgOiBjLnBhbGV0dGVOYW1lLFxuICAgICAgaGVscDogJ1NuYXAgYWxsIGNvbG9ycyB0byBhIGNsYXNzaWMgcGl4ZWwgYXJ0IHBhbGV0dGUuIE11dHVhbGx5IGV4Y2x1c2l2ZSB3aXRoIExvc3BlYyBhbmQgQXV0byBDb2xvcnMuJyxcbiAgICAgIGNoYW5nZWQ6IGMucGFsZXR0ZU5hbWUgIT09IG51bGwsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdsb3NwZWNTbHVnJywgbGFiZWw6ICdMb3NwZWMnLFxuICAgICAgdmFsdWU6IGMubG9zcGVjU2x1ZyA9PT0gbnVsbCA/ICdub25lJyA6IGMubG9zcGVjU2x1ZyxcbiAgICAgIGhlbHA6ICdMb2FkIGFueSBwYWxldHRlIGZyb20gbG9zcGVjLmNvbSBieSBzbHVnIChlLmcuIFwicGljby04XCIsIFwiZW5kZXNnYS0zMlwiKS4gUHJlc3MgRW50ZXIgdG8gdHlwZSBhIHNsdWcgYW5kIGZldGNoIGl0LicsXG4gICAgICBjaGFuZ2VkOiBjLmxvc3BlY1NsdWcgIT09IG51bGwsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdhdXRvQ29sb3JzJywgbGFiZWw6ICdBdXRvIENvbG9ycycsXG4gICAgICB2YWx1ZTogYy5hdXRvQ29sb3JzID09PSBudWxsID8gJ29mZicgOiBTdHJpbmcoYy5hdXRvQ29sb3JzKSxcbiAgICAgIGhlbHA6ICdBdXRvLWV4dHJhY3QgdGhlIGJlc3QgTiBjb2xvcnMgZnJvbSB5b3VyIGltYWdlIHVzaW5nIGstbWVhbnMgY2x1c3RlcmluZyBpbiBPS0xBQiBjb2xvciBzcGFjZS4nLFxuICAgICAgY2hhbmdlZDogYy5hdXRvQ29sb3JzICE9PSBudWxsLFxuICAgIH0sXG4gICAge1xuICAgICAga2V5OiAncGFsZXR0ZUZpbGUnLCBsYWJlbDogJ0xvYWQgLmhleCcsXG4gICAgICB2YWx1ZTogYy5jdXN0b21QYWxldHRlICYmICFjLmxvc3BlY1NsdWcgPyBgJHtjLmN1c3RvbVBhbGV0dGUubGVuZ3RofSBjb2xvcnNgIDogJ25vbmUnLFxuICAgICAgaGVscDogJ0xvYWQgYSBwYWxldHRlIGZyb20gYSAuaGV4IGZpbGUgKG9uZSBoZXggY29sb3IgcGVyIGxpbmUpLiBPdmVycmlkZXMgcGFsZXR0ZSBhbmQgYXV0byBjb2xvcnMuJyxcbiAgICAgIGNoYW5nZWQ6IGMuY3VzdG9tUGFsZXR0ZSAhPT0gbnVsbCAmJiBjLmxvc3BlY1NsdWcgPT09IG51bGwsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdub1F1YW50aXplJywgbGFiZWw6ICdTa2lwIFF1YW50aXplJyxcbiAgICAgIHZhbHVlOiBjLm5vUXVhbnRpemUgPyAnb24nIDogJ29mZicsXG4gICAgICBoZWxwOiAnU2tpcCBjb2xvciBxdWFudGl6YXRpb24gZW50aXJlbHkuIFVzZWZ1bCBpZiB5b3Ugb25seSB3YW50IGdyaWQgc25hcHBpbmcgYW5kIEFBIHJlbW92YWwgd2l0aG91dCBwYWxldHRlIGNoYW5nZXMuJyxcbiAgICAgIGNoYW5nZWQ6IGMubm9RdWFudGl6ZSxcbiAgICB9LFxuICAgIHsgc2VjdGlvbjogJ0JhY2tncm91bmQnIH0sXG4gICAge1xuICAgICAga2V5OiAncmVtb3ZlQmcnLCBsYWJlbDogJ1JlbW92ZSBCRycsXG4gICAgICB2YWx1ZTogYy5yZW1vdmVCZyA/ICdvbicgOiAnb2ZmJyxcbiAgICAgIGhlbHA6ICdEZXRlY3QgYW5kIG1ha2UgdGhlIGJhY2tncm91bmQgdHJhbnNwYXJlbnQuIFRoZSBkb21pbmFudCBib3JkZXIgY29sb3IgaXMgdHJlYXRlZCBhcyBiYWNrZ3JvdW5kLicsXG4gICAgICBjaGFuZ2VkOiBjLnJlbW92ZUJnLFxuICAgIH0sXG4gICAge1xuICAgICAga2V5OiAnYmdDb2xvcicsIGxhYmVsOiAnQkcgQ29sb3InLFxuICAgICAgdmFsdWU6IGMuYmdDb2xvciA9PT0gbnVsbCA/ICdhdXRvJyA6IGMuYmdDb2xvcixcbiAgICAgIGhlbHA6ICdFeHBsaWNpdCBiYWNrZ3JvdW5kIGNvbG9yIGFzIGhleCAoZS5nLiBcIiNGRjAwRkZcIikuIElmIGF1dG8sIGRldGVjdHMgZnJvbSBib3JkZXIgcGl4ZWxzLicsXG4gICAgICBjaGFuZ2VkOiBjLmJnQ29sb3IgIT09IG51bGwsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdib3JkZXJUaHJlc2hvbGQnLCBsYWJlbDogJ0JvcmRlciBUaHJlc2gnLFxuICAgICAgdmFsdWU6IGMuYm9yZGVyVGhyZXNob2xkID09PSBudWxsID8gJzAuNDAnIDogYy5ib3JkZXJUaHJlc2hvbGQudG9GaXhlZCgyKSxcbiAgICAgIGhlbHA6ICdGcmFjdGlvbiBvZiBib3JkZXIgcGl4ZWxzIHRoYXQgbXVzdCBtYXRjaCBmb3IgYXV0by1kZXRlY3Rpb24gKDAuMFxcdTIwMTMxLjAsIGRlZmF1bHQ6IDAuNDApLicsXG4gICAgICBjaGFuZ2VkOiBjLmJvcmRlclRocmVzaG9sZCAhPT0gbnVsbCxcbiAgICB9LFxuICAgIHtcbiAgICAgIGtleTogJ2JnVG9sZXJhbmNlJywgbGFiZWw6ICdCRyBUb2xlcmFuY2UnLFxuICAgICAgdmFsdWU6IGMuYmdUb2xlcmFuY2UudG9GaXhlZCgyKSxcbiAgICAgIGhlbHA6ICdIb3cgZGlmZmVyZW50IGEgcGl4ZWwgY2FuIGJlIGZyb20gdGhlIGJhY2tncm91bmQgY29sb3IgYW5kIHN0aWxsIGNvdW50IGFzIGJhY2tncm91bmQuIEhpZ2hlciA9IG1vcmUgYWdncmVzc2l2ZS4nLFxuICAgICAgY2hhbmdlZDogYy5iZ1RvbGVyYW5jZSAhPT0gMC4wNSxcbiAgICB9LFxuICAgIHtcbiAgICAgIGtleTogJ2Zsb29kRmlsbCcsIGxhYmVsOiAnRmxvb2QgRmlsbCcsXG4gICAgICB2YWx1ZTogYy5mbG9vZEZpbGwgPyAnb24nIDogJ29mZicsXG4gICAgICBoZWxwOiAnT246IG9ubHkgcmVtb3ZlIGNvbm5lY3RlZCBiYWNrZ3JvdW5kIGZyb20gZWRnZXMuIE9mZjogcmVtb3ZlIG1hdGNoaW5nIGNvbG9yIGV2ZXJ5d2hlcmUuJyxcbiAgICAgIGNoYW5nZWQ6ICFjLmZsb29kRmlsbCxcbiAgICB9LFxuICAgIHsgc2VjdGlvbjogJ091dHB1dCcgfSxcbiAgICB7XG4gICAgICBrZXk6ICdvdXRwdXRTY2FsZScsIGxhYmVsOiAnU2NhbGUnLFxuICAgICAgdmFsdWU6IGMub3V0cHV0U2NhbGUgPT09IG51bGwgPyAnb2ZmJyA6IGMub3V0cHV0U2NhbGUgKyAneCcsXG4gICAgICBoZWxwOiAnU2NhbGUgdGhlIG91dHB1dCBieSBhbiBpbnRlZ2VyIG11bHRpcGxpZXIgKDJ4LCAzeCwgZXRjKS4gR3JlYXQgZm9yIHVwc2NhbGluZyBzcHJpdGVzIGZvciBnYW1lIGVuZ2luZXMuJyxcbiAgICAgIGNoYW5nZWQ6IGMub3V0cHV0U2NhbGUgIT09IG51bGwsXG4gICAgfSxcbiAgICB7XG4gICAgICBrZXk6ICdvdXRwdXRXaWR0aCcsIGxhYmVsOiAnV2lkdGgnLFxuICAgICAgdmFsdWU6IGMub3V0cHV0V2lkdGggPT09IG51bGwgPyAnYXV0bycgOiBTdHJpbmcoYy5vdXRwdXRXaWR0aCksXG4gICAgICBoZWxwOiAnRXhwbGljaXQgb3V0cHV0IHdpZHRoIGluIHBpeGVscy4gT3ZlcnJpZGVzIHNjYWxlLicsXG4gICAgICBjaGFuZ2VkOiBjLm91dHB1dFdpZHRoICE9PSBudWxsLFxuICAgIH0sXG4gICAge1xuICAgICAga2V5OiAnb3V0cHV0SGVpZ2h0JywgbGFiZWw6ICdIZWlnaHQnLFxuICAgICAgdmFsdWU6IGMub3V0cHV0SGVpZ2h0ID09PSBudWxsID8gJ2F1dG8nIDogU3RyaW5nKGMub3V0cHV0SGVpZ2h0KSxcbiAgICAgIGhlbHA6ICdFeHBsaWNpdCBvdXRwdXQgaGVpZ2h0IGluIHBpeGVscy4gT3ZlcnJpZGVzIHNjYWxlLicsXG4gICAgICBjaGFuZ2VkOiBjLm91dHB1dEhlaWdodCAhPT0gbnVsbCxcbiAgICB9LFxuICBdO1xufVxuXG5mdW5jdGlvbiBnZXRTZXR0aW5nUm93cygpOiBTZXR0aW5nUm93W10ge1xuICByZXR1cm4gZ2V0U2V0dGluZ3MoKS5maWx0ZXIoKHMpOiBzIGlzIFNldHRpbmdSb3cgPT4gIXMuc2VjdGlvbik7XG59XG5cbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuLy8gU2V0dGluZyBhZGp1c3RtZW50IChhcnJvdyBrZXlzKVxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmZ1bmN0aW9uIGFkanVzdFNldHRpbmcoa2V5OiBzdHJpbmcsIGRpcmVjdGlvbjogbnVtYmVyKTogdm9pZCB7XG4gIGNvbnN0IGMgPSBzdGF0ZS5jb25maWc7XG4gIHN3aXRjaCAoa2V5KSB7XG4gICAgY2FzZSAnZ3JpZFNpemUnOlxuICAgICAgaWYgKGMuZ3JpZFNpemUgPT09IG51bGwpIHtcbiAgICAgICAgYy5ncmlkU2l6ZSA9IHN0YXRlLmltYWdlSW5mbz8uZ3JpZFNpemUgfHwgNDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGMuZ3JpZFNpemUgPSBNYXRoLm1heCgxLCBjLmdyaWRTaXplICsgZGlyZWN0aW9uKTtcbiAgICAgICAgaWYgKGMuZ3JpZFNpemUgPT09IDEgJiYgZGlyZWN0aW9uIDwgMCkgYy5ncmlkU2l6ZSA9IG51bGw7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdncmlkUGhhc2VYJzpcbiAgICAgIGlmIChjLmdyaWRQaGFzZVggPT09IG51bGwpIHtcbiAgICAgICAgYy5ncmlkUGhhc2VYID0gMDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGMuZ3JpZFBoYXNlWCA9IE1hdGgubWF4KDAsIGMuZ3JpZFBoYXNlWCArIGRpcmVjdGlvbik7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdncmlkUGhhc2VZJzpcbiAgICAgIGlmIChjLmdyaWRQaGFzZVkgPT09IG51bGwpIHtcbiAgICAgICAgYy5ncmlkUGhhc2VZID0gMDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGMuZ3JpZFBoYXNlWSA9IE1hdGgubWF4KDAsIGMuZ3JpZFBoYXNlWSArIGRpcmVjdGlvbik7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdtYXhHcmlkQ2FuZGlkYXRlJzpcbiAgICAgIGMubWF4R3JpZENhbmRpZGF0ZSA9IE1hdGgubWF4KDIsIE1hdGgubWluKDY0LCBjLm1heEdyaWRDYW5kaWRhdGUgKyBkaXJlY3Rpb24gKiA0KSk7XG4gICAgICBicmVhaztcbiAgICBjYXNlICdub0dyaWREZXRlY3QnOlxuICAgICAgYy5ub0dyaWREZXRlY3QgPSAhYy5ub0dyaWREZXRlY3Q7XG4gICAgICBicmVhaztcbiAgICBjYXNlICdkb3duc2NhbGVNb2RlJzoge1xuICAgICAgbGV0IGlkeCA9IERPV05TQ0FMRV9NT0RFUy5pbmRleE9mKGMuZG93bnNjYWxlTW9kZSk7XG4gICAgICBpZHggPSAoaWR4ICsgZGlyZWN0aW9uICsgRE9XTlNDQUxFX01PREVTLmxlbmd0aCkgJSBET1dOU0NBTEVfTU9ERVMubGVuZ3RoO1xuICAgICAgYy5kb3duc2NhbGVNb2RlID0gRE9XTlNDQUxFX01PREVTW2lkeF07XG4gICAgICBicmVhaztcbiAgICB9XG4gICAgY2FzZSAnYWFUaHJlc2hvbGQnOlxuICAgICAgaWYgKGMuYWFUaHJlc2hvbGQgPT09IG51bGwpIHtcbiAgICAgICAgYy5hYVRocmVzaG9sZCA9IDAuNTA7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBjLmFhVGhyZXNob2xkID0gTWF0aC5yb3VuZCgoYy5hYVRocmVzaG9sZCArIGRpcmVjdGlvbiAqIDAuMDUpICogMTAwKSAvIDEwMDtcbiAgICAgICAgaWYgKGMuYWFUaHJlc2hvbGQgPD0gMCkgYy5hYVRocmVzaG9sZCA9IG51bGw7XG4gICAgICAgIGVsc2UgaWYgKGMuYWFUaHJlc2hvbGQgPiAxLjApIGMuYWFUaHJlc2hvbGQgPSAxLjA7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdwYWxldHRlTmFtZSc6IHtcbiAgICAgIGNvbnN0IG5hbWVzOiAoc3RyaW5nIHwgbnVsbClbXSA9IFtudWxsLCAuLi5zdGF0ZS5wYWxldHRlcy5tYXAocCA9PiBwLnNsdWcpXTtcbiAgICAgIGxldCBpZHggPSBuYW1lcy5pbmRleE9mKGMucGFsZXR0ZU5hbWUpO1xuICAgICAgaWR4ID0gKGlkeCArIGRpcmVjdGlvbiArIG5hbWVzLmxlbmd0aCkgJSBuYW1lcy5sZW5ndGg7XG4gICAgICBjLnBhbGV0dGVOYW1lID0gbmFtZXNbaWR4XTtcbiAgICAgIGlmIChjLnBhbGV0dGVOYW1lICE9PSBudWxsKSB7XG4gICAgICAgIGMuYXV0b0NvbG9ycyA9IG51bGw7XG4gICAgICAgIGMubG9zcGVjU2x1ZyA9IG51bGw7XG4gICAgICAgIGMuY3VzdG9tUGFsZXR0ZSA9IG51bGw7XG4gICAgICAgIHN0YXRlLmxvc3BlY1Jlc3VsdCA9IG51bGw7XG4gICAgICAgIGZldGNoUGFsZXR0ZUNvbG9ycyhjLnBhbGV0dGVOYW1lKTtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHN0YXRlLnBhbGV0dGVDb2xvcnMgPSBudWxsO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgfVxuICAgIGNhc2UgJ2F1dG9Db2xvcnMnOlxuICAgICAgaWYgKGMuYXV0b0NvbG9ycyA9PT0gbnVsbCkge1xuICAgICAgICBjLmF1dG9Db2xvcnMgPSAxNjtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGMuYXV0b0NvbG9ycyA9IE1hdGgubWF4KDIsIGMuYXV0b0NvbG9ycyArIGRpcmVjdGlvbiAqIDIpO1xuICAgICAgICBpZiAoYy5hdXRvQ29sb3JzIDw9IDIgJiYgZGlyZWN0aW9uIDwgMCkgYy5hdXRvQ29sb3JzID0gbnVsbDtcbiAgICAgICAgZWxzZSBpZiAoYy5hdXRvQ29sb3JzID4gMjU2KSBjLmF1dG9Db2xvcnMgPSAyNTY7XG4gICAgICB9XG4gICAgICBpZiAoYy5hdXRvQ29sb3JzICE9PSBudWxsKSB7XG4gICAgICAgIGMucGFsZXR0ZU5hbWUgPSBudWxsO1xuICAgICAgICBjLmxvc3BlY1NsdWcgPSBudWxsO1xuICAgICAgICBjLmN1c3RvbVBhbGV0dGUgPSBudWxsO1xuICAgICAgICBzdGF0ZS5wYWxldHRlQ29sb3JzID0gbnVsbDtcbiAgICAgICAgc3RhdGUubG9zcGVjUmVzdWx0ID0gbnVsbDtcbiAgICAgIH1cbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ3JlbW92ZUJnJzpcbiAgICAgIGMucmVtb3ZlQmcgPSAhYy5yZW1vdmVCZztcbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ2JvcmRlclRocmVzaG9sZCc6XG4gICAgICBpZiAoYy5ib3JkZXJUaHJlc2hvbGQgPT09IG51bGwpIHtcbiAgICAgICAgYy5ib3JkZXJUaHJlc2hvbGQgPSAwLjQwO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgYy5ib3JkZXJUaHJlc2hvbGQgPSBNYXRoLnJvdW5kKChjLmJvcmRlclRocmVzaG9sZCArIGRpcmVjdGlvbiAqIDAuMDUpICogMTAwKSAvIDEwMDtcbiAgICAgICAgaWYgKGMuYm9yZGVyVGhyZXNob2xkIDw9IDApIGMuYm9yZGVyVGhyZXNob2xkID0gbnVsbDtcbiAgICAgICAgZWxzZSBpZiAoYy5ib3JkZXJUaHJlc2hvbGQgPiAxLjApIGMuYm9yZGVyVGhyZXNob2xkID0gMS4wO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnYmdUb2xlcmFuY2UnOlxuICAgICAgYy5iZ1RvbGVyYW5jZSA9IE1hdGgucm91bmQoKGMuYmdUb2xlcmFuY2UgKyBkaXJlY3Rpb24gKiAwLjAxKSAqIDEwMCkgLyAxMDA7XG4gICAgICBjLmJnVG9sZXJhbmNlID0gTWF0aC5tYXgoMC4wMSwgTWF0aC5taW4oMC41MCwgYy5iZ1RvbGVyYW5jZSkpO1xuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnZmxvb2RGaWxsJzpcbiAgICAgIGMuZmxvb2RGaWxsID0gIWMuZmxvb2RGaWxsO1xuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnb3V0cHV0U2NhbGUnOlxuICAgICAgaWYgKGMub3V0cHV0U2NhbGUgPT09IG51bGwpIHtcbiAgICAgICAgYy5vdXRwdXRTY2FsZSA9IDI7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBjLm91dHB1dFNjYWxlID0gYy5vdXRwdXRTY2FsZSArIGRpcmVjdGlvbjtcbiAgICAgICAgaWYgKGMub3V0cHV0U2NhbGUgPCAyKSBjLm91dHB1dFNjYWxlID0gbnVsbDtcbiAgICAgICAgZWxzZSBpZiAoYy5vdXRwdXRTY2FsZSA+IDE2KSBjLm91dHB1dFNjYWxlID0gMTY7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdvdXRwdXRXaWR0aCc6XG4gICAgICBpZiAoYy5vdXRwdXRXaWR0aCA9PT0gbnVsbCkge1xuICAgICAgICBjLm91dHB1dFdpZHRoID0gc3RhdGUuaW1hZ2VJbmZvPy53aWR0aCB8fCA2NDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGMub3V0cHV0V2lkdGggPSBNYXRoLm1heCgxLCBjLm91dHB1dFdpZHRoICsgZGlyZWN0aW9uICogOCk7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdvdXRwdXRIZWlnaHQnOlxuICAgICAgaWYgKGMub3V0cHV0SGVpZ2h0ID09PSBudWxsKSB7XG4gICAgICAgIGMub3V0cHV0SGVpZ2h0ID0gc3RhdGUuaW1hZ2VJbmZvPy5oZWlnaHQgfHwgNjQ7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBjLm91dHB1dEhlaWdodCA9IE1hdGgubWF4KDEsIGMub3V0cHV0SGVpZ2h0ICsgZGlyZWN0aW9uICogOCk7XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgfVxufVxuXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cbi8vIFBhbGV0dGUgY29sb3JzIGZldGNoaW5nXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuYXN5bmMgZnVuY3Rpb24gZmV0Y2hQYWxldHRlQ29sb3JzKHNsdWc6IHN0cmluZyk6IFByb21pc2U8dm9pZD4ge1xuICB0cnkge1xuICAgIGNvbnN0IGNvbG9ycyA9IGF3YWl0IGludm9rZTxzdHJpbmdbXT4oJ2dldF9wYWxldHRlX2NvbG9ycycsIHsgc2x1ZyB9KTtcbiAgICBzdGF0ZS5wYWxldHRlQ29sb3JzID0gY29sb3JzO1xuICAgIHJlbmRlclNldHRpbmdzKCk7XG4gIH0gY2F0Y2gge1xuICAgIHN0YXRlLnBhbGV0dGVDb2xvcnMgPSBudWxsO1xuICB9XG59XG5cbmFzeW5jIGZ1bmN0aW9uIGZldGNoTG9zcGVjKHNsdWc6IHN0cmluZyk6IFByb21pc2U8dm9pZD4ge1xuICBzdGF0ZS5sb3NwZWNMb2FkaW5nID0gdHJ1ZTtcbiAgc3RhdGUubG9zcGVjRXJyb3IgPSBudWxsO1xuICByZW5kZXJTZXR0aW5ncygpO1xuICB0cnkge1xuICAgIGNvbnN0IHJlc3VsdCA9IGF3YWl0IGludm9rZTxMb3NwZWNSZXN1bHQ+KCdmZXRjaF9sb3NwZWMnLCB7IHNsdWcgfSk7XG4gICAgc3RhdGUubG9zcGVjUmVzdWx0ID0gcmVzdWx0O1xuICAgIHN0YXRlLmNvbmZpZy5sb3NwZWNTbHVnID0gc2x1ZztcbiAgICBzdGF0ZS5jb25maWcuY3VzdG9tUGFsZXR0ZSA9IHJlc3VsdC5jb2xvcnM7XG4gICAgc3RhdGUuY29uZmlnLnBhbGV0dGVOYW1lID0gbnVsbDtcbiAgICBzdGF0ZS5jb25maWcuYXV0b0NvbG9ycyA9IG51bGw7XG4gICAgc3RhdGUucGFsZXR0ZUNvbG9ycyA9IHJlc3VsdC5jb2xvcnM7XG4gICAgc3RhdGUubG9zcGVjTG9hZGluZyA9IGZhbHNlO1xuICAgIHJlbmRlclNldHRpbmdzKCk7XG4gICAgYXV0b1Byb2Nlc3MoKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIHN0YXRlLmxvc3BlY0Vycm9yID0gU3RyaW5nKGUpO1xuICAgIHN0YXRlLmxvc3BlY0xvYWRpbmcgPSBmYWxzZTtcbiAgICByZW5kZXJTZXR0aW5ncygpO1xuICB9XG59XG5cbmFzeW5jIGZ1bmN0aW9uIGxvYWRQYWxldHRlRmlsZURpYWxvZygpOiBQcm9taXNlPHZvaWQ+IHtcbiAgdHJ5IHtcbiAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBvcGVuRGlhbG9nKHtcbiAgICAgIG11bHRpcGxlOiBmYWxzZSxcbiAgICAgIGZpbHRlcnM6IFt7XG4gICAgICAgIG5hbWU6ICdQYWxldHRlIEZpbGVzJyxcbiAgICAgICAgZXh0ZW5zaW9uczogWydoZXgnLCAndHh0J10sXG4gICAgICB9XSxcbiAgICB9KTtcbiAgICBpZiAocmVzdWx0KSB7XG4gICAgICBjb25zdCBjb2xvcnMgPSBhd2FpdCBpbnZva2U8c3RyaW5nW10+KCdsb2FkX3BhbGV0dGVfZmlsZScsIHsgcGF0aDogcmVzdWx0IH0pO1xuICAgICAgc3RhdGUuY29uZmlnLmN1c3RvbVBhbGV0dGUgPSBjb2xvcnM7XG4gICAgICBzdGF0ZS5jb25maWcucGFsZXR0ZU5hbWUgPSBudWxsO1xuICAgICAgc3RhdGUuY29uZmlnLmF1dG9Db2xvcnMgPSBudWxsO1xuICAgICAgc3RhdGUuY29uZmlnLmxvc3BlY1NsdWcgPSBudWxsO1xuICAgICAgc3RhdGUubG9zcGVjUmVzdWx0ID0gbnVsbDtcbiAgICAgIHN0YXRlLnBhbGV0dGVDb2xvcnMgPSBjb2xvcnM7XG4gICAgICByZW5kZXJTZXR0aW5ncygpO1xuICAgICAgYXV0b1Byb2Nlc3MoKTtcbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBzZXRTdGF0dXMoJ0Vycm9yIGxvYWRpbmcgcGFsZXR0ZTogJyArIGUsICdlcnJvcicpO1xuICB9XG59XG5cbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuLy8gVUkgcmVuZGVyaW5nXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuZnVuY3Rpb24gc2V0U3RhdHVzKG1zZzogc3RyaW5nLCB0eXBlOiBzdHJpbmcgPSAnJyk6IHZvaWQge1xuICBjb25zdCBlbCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzdGF0dXMtbXNnJykhO1xuICBlbC50ZXh0Q29udGVudCA9IG1zZztcbiAgZWwuY2xhc3NOYW1lID0gJ3N0YXR1cy1tc2cnICsgKHR5cGUgPyAnICcgKyB0eXBlIDogJycpO1xuICAvLyBTaG93L2hpZGUgc3RhdHVzIGJhciBzcGlubmVyIGJhc2VkIG9uIHByb2Nlc3Npbmcgc3RhdGVcbiAgY29uc3Qgc3Bpbm5lciA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzdGF0dXMtc3Bpbm5lcicpITtcbiAgaWYgKHR5cGUgPT09ICdwcm9jZXNzaW5nJykge1xuICAgIHNwaW5uZXIuY2xhc3NMaXN0LmFkZCgnYWN0aXZlJyk7XG4gIH0gZWxzZSB7XG4gICAgc3Bpbm5lci5jbGFzc0xpc3QucmVtb3ZlKCdhY3RpdmUnKTtcbiAgfVxufVxuXG5mdW5jdGlvbiBzaG93V2VsY29tZUxvYWRpbmcoKTogdm9pZCB7XG4gIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCd3ZWxjb21lLWxvYWRpbmcnKSEuc3R5bGUuZGlzcGxheSA9ICdmbGV4Jztcbn1cblxuZnVuY3Rpb24gaGlkZVdlbGNvbWVMb2FkaW5nKCk6IHZvaWQge1xuICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnd2VsY29tZS1sb2FkaW5nJykhLnN0eWxlLmRpc3BsYXkgPSAnbm9uZSc7XG59XG5cbmZ1bmN0aW9uIHN3aXRjaFRhYihuYW1lOiBzdHJpbmcpOiB2b2lkIHtcbiAgc3RhdGUuYWN0aXZlVGFiID0gbmFtZTtcbiAgZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLnRhYicpLmZvckVhY2godCA9PiB7XG4gICAgKHQgYXMgSFRNTEVsZW1lbnQpLmNsYXNzTGlzdC50b2dnbGUoJ2FjdGl2ZScsICh0IGFzIEhUTUxFbGVtZW50KS5kYXRhc2V0LnRhYiA9PT0gbmFtZSk7XG4gIH0pO1xuICBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudGFiLXBhbmVsJykuZm9yRWFjaChwID0+IHtcbiAgICAocCBhcyBIVE1MRWxlbWVudCkuY2xhc3NMaXN0LnRvZ2dsZSgnYWN0aXZlJywgcC5pZCA9PT0gJ3BhbmVsLScgKyBuYW1lKTtcbiAgfSk7XG4gIC8vIFJlLXJlbmRlciBkeW5hbWljIHRhYnMgdG8gcmVmbGVjdCBsYXRlc3Qgc3RhdGVcbiAgaWYgKG5hbWUgPT09ICdiYXRjaCcpIHJlbmRlckJhdGNoKCk7XG4gIGlmIChuYW1lID09PSAnc2hlZXQnKSByZW5kZXJTaGVldCgpO1xufVxuXG4vLyBTZXR0aW5ncyB0aGF0IGFsd2F5cyByZW5kZXIgYXMgPHNlbGVjdD4gZHJvcGRvd25zXG5jb25zdCBTRUxFQ1RfU0VUVElOR1MgPSBbJ2Rvd25zY2FsZU1vZGUnLCAncGFsZXR0ZU5hbWUnXTtcbi8vIFNldHRpbmdzIHRoYXQgYXJlIGJvb2xlYW4gdG9nZ2xlc1xuY29uc3QgQk9PTEVBTl9TRVRUSU5HUyA9IFsncmVtb3ZlQmcnLCAnZmxvb2RGaWxsJywgJ25vR3JpZERldGVjdCcsICdub1F1YW50aXplJ107XG4vLyBTZXR0aW5ncyB0aGF0IHJlcXVpcmUgRW50ZXItdG8tZWRpdCAodGV4dC9udW1lcmljIGlucHV0KVxuY29uc3QgSU5QVVRfU0VUVElOR1MgPSBbJ2dyaWRTaXplJywgJ2dyaWRQaGFzZVgnLCAnZ3JpZFBoYXNlWScsICdtYXhHcmlkQ2FuZGlkYXRlJywgJ2FhVGhyZXNob2xkJywgJ2F1dG9Db2xvcnMnLCAnYmdDb2xvcicsICdib3JkZXJUaHJlc2hvbGQnLCAnYmdUb2xlcmFuY2UnLCAnbG9zcGVjU2x1ZycsICdvdXRwdXRTY2FsZScsICdvdXRwdXRXaWR0aCcsICdvdXRwdXRIZWlnaHQnXTtcbi8vIFNldHRpbmdzIHRoYXQgb3BlbiBhIGZpbGUgZGlhbG9nIGluc3RlYWQgb2YgZWRpdGluZ1xuY29uc3QgRklMRV9TRVRUSU5HUyA9IFsncGFsZXR0ZUZpbGUnXTtcbi8vIE51bGxhYmxlIHNldHRpbmdzIOKAlCBjYW4gYmUgdHVybmVkIG9mZiAobnVsbCkgd2l0aCBhIGNsZWFyIGJ1dHRvblxuY29uc3QgTlVMTEFCTEVfU0VUVElOR1M6IFJlY29yZDxzdHJpbmcsIHsgb2ZmTGFiZWw6IHN0cmluZzsgZGVmYXVsdFZhbHVlOiAoKSA9PiB1bmtub3duIH0+ID0ge1xuICBncmlkU2l6ZTogICAgICAgICB7IG9mZkxhYmVsOiAnYXV0bycsICBkZWZhdWx0VmFsdWU6ICgpID0+IHN0YXRlLmltYWdlSW5mbz8uZ3JpZFNpemUgfHwgNCB9LFxuICBncmlkUGhhc2VYOiAgICAgICB7IG9mZkxhYmVsOiAnYXV0bycsICBkZWZhdWx0VmFsdWU6ICgpID0+IDAgfSxcbiAgZ3JpZFBoYXNlWTogICAgICAgeyBvZmZMYWJlbDogJ2F1dG8nLCAgZGVmYXVsdFZhbHVlOiAoKSA9PiAwIH0sXG4gIGFhVGhyZXNob2xkOiAgICAgIHsgb2ZmTGFiZWw6ICdvZmYnLCAgIGRlZmF1bHRWYWx1ZTogKCkgPT4gMC41MCB9LFxuICBhdXRvQ29sb3JzOiAgICAgICB7IG9mZkxhYmVsOiAnb2ZmJywgICBkZWZhdWx0VmFsdWU6ICgpID0+IDE2IH0sXG4gIGxvc3BlY1NsdWc6ICAgICAgIHsgb2ZmTGFiZWw6ICdub25lJywgIGRlZmF1bHRWYWx1ZTogKCkgPT4gbnVsbCB9LCAvLyBsb3NwZWMgYWx3YXlzIG9wZW5zIGlucHV0XG4gIGJnQ29sb3I6ICAgICAgICAgIHsgb2ZmTGFiZWw6ICdhdXRvJywgIGRlZmF1bHRWYWx1ZTogKCkgPT4gbnVsbCB9LCAvLyBiZ0NvbG9yIG9wZW5zIGlucHV0XG4gIGJvcmRlclRocmVzaG9sZDogIHsgb2ZmTGFiZWw6ICcwLjQwJywgIGRlZmF1bHRWYWx1ZTogKCkgPT4gMC40MCB9LFxuICBvdXRwdXRTY2FsZTogICAgICB7IG9mZkxhYmVsOiAnb2ZmJywgICBkZWZhdWx0VmFsdWU6ICgpID0+IDIgfSxcbiAgb3V0cHV0V2lkdGg6ICAgICAgeyBvZmZMYWJlbDogJ2F1dG8nLCAgZGVmYXVsdFZhbHVlOiAoKSA9PiBzdGF0ZS5pbWFnZUluZm8/LndpZHRoIHx8IDY0IH0sXG4gIG91dHB1dEhlaWdodDogICAgIHsgb2ZmTGFiZWw6ICdhdXRvJywgIGRlZmF1bHRWYWx1ZTogKCkgPT4gc3RhdGUuaW1hZ2VJbmZvPy5oZWlnaHQgfHwgNjQgfSxcbn07XG5cbmZ1bmN0aW9uIHJlbmRlclNldHRpbmdzKCk6IHZvaWQge1xuICBjb25zdCBsaXN0ID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3NldHRpbmdzLWxpc3QnKSE7XG5cbiAgLy8gRG9uJ3QgY2xvYmJlciB0aGUgRE9NIHdoaWxlIHRoZSB1c2VyIGlzIGZvY3VzZWQgb24gYW4gaW5saW5lIGlucHV0XG4gIGNvbnN0IGZvY3VzZWQgPSBkb2N1bWVudC5hY3RpdmVFbGVtZW50O1xuICBpZiAoZm9jdXNlZCAmJiBmb2N1c2VkLmNsYXNzTGlzdD8uY29udGFpbnMoJ3NldHRpbmctaW5saW5lLWlucHV0JykgJiYgbGlzdC5jb250YWlucyhmb2N1c2VkKSkge1xuICAgIC8vIFVwZGF0ZSBub24taW5wdXQgcGFydHMgb25seTogZm9jdXMgaW5kaWNhdG9yLCBjaGFuZ2VkIGNsYXNzZXNcbiAgICB1cGRhdGVTZXR0aW5nc0ZvY3VzT25seShsaXN0KTtcbiAgICByZXR1cm47XG4gIH1cblxuICBjb25zdCBzZXR0aW5ncyA9IGdldFNldHRpbmdzKCk7XG4gIGxldCByb3dJbmRleCA9IDA7XG4gIGxldCBodG1sID0gJyc7XG5cbiAgZm9yIChjb25zdCBzIG9mIHNldHRpbmdzKSB7XG4gICAgaWYgKHMuc2VjdGlvbikge1xuICAgICAgaHRtbCArPSBgPGRpdiBjbGFzcz1cInNldHRpbmctc2VjdGlvblwiPiR7cy5zZWN0aW9ufTwvZGl2PmA7XG4gICAgfSBlbHNlIHtcbiAgICAgIGNvbnN0IGlzRm9jdXNlZCA9IHJvd0luZGV4ID09PSBzdGF0ZS5zZXR0aW5nc0ZvY3VzSW5kZXggPyAnIGZvY3VzZWQnIDogJyc7XG4gICAgICBjb25zdCBjaGFuZ2VkID0gcy5jaGFuZ2VkID8gJyBjaGFuZ2VkJyA6ICcnO1xuXG4gICAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwic2V0dGluZy1yb3cke2lzRm9jdXNlZH1cIiBkYXRhLWluZGV4PVwiJHtyb3dJbmRleH1cIiBkYXRhLWtleT1cIiR7cy5rZXl9XCI+YDtcbiAgICAgIGh0bWwgKz0gYDxzcGFuIGNsYXNzPVwic2V0dGluZy1pbmRpY2F0b3JcIj4mIzk2NTQ7PC9zcGFuPmA7XG4gICAgICBodG1sICs9IGA8c3BhbiBjbGFzcz1cInNldHRpbmctbGFiZWxcIj4ke3MubGFiZWx9PC9zcGFuPmA7XG4gICAgICBodG1sICs9IGA8c3BhbiBjbGFzcz1cInNldHRpbmctdmFsdWUke2NoYW5nZWR9XCI+YDtcblxuICAgICAgaWYgKFNFTEVDVF9TRVRUSU5HUy5pbmNsdWRlcyhzLmtleSkpIHtcbiAgICAgICAgLy8gQWx3YXlzIHJlbmRlciBhcyBkcm9wZG93blxuICAgICAgICBodG1sICs9IHJlbmRlcklubGluZVNlbGVjdChzLmtleSk7XG4gICAgICB9IGVsc2UgaWYgKEJPT0xFQU5fU0VUVElOR1MuaW5jbHVkZXMocy5rZXkpKSB7XG4gICAgICAgIC8vIFJlbmRlciBhcyBjbGlja2FibGUgdG9nZ2xlXG4gICAgICAgIGh0bWwgKz0gYDxzcGFuIGNsYXNzPVwic2V0dGluZy10b2dnbGVcIiBkYXRhLWtleT1cIiR7cy5rZXl9XCI+JHtlc2NhcGVIdG1sKHMudmFsdWUpfTwvc3Bhbj5gO1xuICAgICAgfSBlbHNlIGlmIChGSUxFX1NFVFRJTkdTLmluY2x1ZGVzKHMua2V5KSkge1xuICAgICAgICAvLyBGaWxlIHNldHRpbmdzOiBjbGlja2FibGUgdG8gb3BlbiBkaWFsb2csIHdpdGggY2xlYXIgYnV0dG9uIHdoZW4gYWN0aXZlXG4gICAgICAgIGlmIChzLmNoYW5nZWQpIHtcbiAgICAgICAgICBodG1sICs9IGVzY2FwZUh0bWwocy52YWx1ZSk7XG4gICAgICAgICAgaHRtbCArPSBgPHNwYW4gY2xhc3M9XCJzZXR0aW5nLWNsZWFyXCIgZGF0YS1rZXk9XCIke3Mua2V5fVwiIHRpdGxlPVwiQ2xlYXJcIj5cXHUwMGQ3PC9zcGFuPmA7XG4gICAgICAgIH0gZWxzZSB7XG4gICAgICAgICAgaHRtbCArPSBgPHNwYW4gY2xhc3M9XCJzZXR0aW5nLXRvZ2dsZVwiIGRhdGEta2V5PVwiJHtzLmtleX1cIj4ke2VzY2FwZUh0bWwocy52YWx1ZSl9PC9zcGFuPmA7XG4gICAgICAgIH1cbiAgICAgIH0gZWxzZSBpZiAoSU5QVVRfU0VUVElOR1MuaW5jbHVkZXMocy5rZXkpKSB7XG4gICAgICAgIC8vIEFsd2F5cy12aXNpYmxlIGlubGluZSBpbnB1dFxuICAgICAgICBodG1sICs9IHJlbmRlcklubGluZUlucHV0KHMua2V5KTtcbiAgICAgICAgaWYgKHMua2V5IGluIE5VTExBQkxFX1NFVFRJTkdTICYmIHMuY2hhbmdlZCkge1xuICAgICAgICAgIGNvbnN0IG51bGxhYmxlID0gTlVMTEFCTEVfU0VUVElOR1Nbcy5rZXldO1xuICAgICAgICAgIGh0bWwgKz0gYDxzcGFuIGNsYXNzPVwic2V0dGluZy1jbGVhclwiIGRhdGEta2V5PVwiJHtzLmtleX1cIiB0aXRsZT1cIlJlc2V0IHRvICR7bnVsbGFibGUub2ZmTGFiZWx9XCI+XFx1MDBkNzwvc3Bhbj5gO1xuICAgICAgICB9XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBodG1sICs9IGVzY2FwZUh0bWwocy52YWx1ZSk7XG4gICAgICB9XG5cbiAgICAgIGh0bWwgKz0gYDwvc3Bhbj5gO1xuICAgICAgaHRtbCArPSBgPC9kaXY+YDtcbiAgICAgIC8vIEhlbHAgdGV4dCBhbHdheXMgdmlzaWJsZVxuICAgICAgaHRtbCArPSBgPGRpdiBjbGFzcz1cInNldHRpbmctaGVscFwiPiR7cy5oZWxwfTwvZGl2PmA7XG5cbiAgICAgIC8vIFBhbGV0dGUgc3dhdGNoZXMgKGFmdGVyIHBhbGV0dGUsIGxvc3BlYywgb3IgcGFsZXR0ZUZpbGUgcm93KVxuICAgICAgaWYgKChzLmtleSA9PT0gJ3BhbGV0dGVOYW1lJyB8fCBzLmtleSA9PT0gJ2xvc3BlY1NsdWcnIHx8IHMua2V5ID09PSAncGFsZXR0ZUZpbGUnKSAmJiBzdGF0ZS5wYWxldHRlQ29sb3JzICYmIHN0YXRlLnBhbGV0dGVDb2xvcnMubGVuZ3RoID4gMCkge1xuICAgICAgICBpZiAoKHMua2V5ID09PSAncGFsZXR0ZU5hbWUnICYmIHN0YXRlLmNvbmZpZy5wYWxldHRlTmFtZSAhPT0gbnVsbCkgfHxcbiAgICAgICAgICAgIChzLmtleSA9PT0gJ2xvc3BlY1NsdWcnICYmIHN0YXRlLmNvbmZpZy5sb3NwZWNTbHVnICE9PSBudWxsKSB8fFxuICAgICAgICAgICAgKHMua2V5ID09PSAncGFsZXR0ZUZpbGUnICYmIHN0YXRlLmNvbmZpZy5jdXN0b21QYWxldHRlICE9PSBudWxsICYmIHN0YXRlLmNvbmZpZy5sb3NwZWNTbHVnID09PSBudWxsKSkge1xuICAgICAgICAgIGh0bWwgKz0gcmVuZGVyUGFsZXR0ZVN3YXRjaGVzKHN0YXRlLnBhbGV0dGVDb2xvcnMpO1xuICAgICAgICB9XG4gICAgICB9XG5cbiAgICAgIC8vIExvc3BlYyBpbmZvL2Vycm9yIGFmdGVyIGxvc3BlYyByb3dcbiAgICAgIGlmIChzLmtleSA9PT0gJ2xvc3BlY1NsdWcnKSB7XG4gICAgICAgIGlmIChzdGF0ZS5sb3NwZWNMb2FkaW5nKSB7XG4gICAgICAgICAgaHRtbCArPSBgPGRpdiBjbGFzcz1cImxvc3BlYy1pbmZvIGxvc3BlYy1sb2FkaW5nXCI+RmV0Y2hpbmcgcGFsZXR0ZS4uLjwvZGl2PmA7XG4gICAgICAgIH0gZWxzZSBpZiAoc3RhdGUubG9zcGVjRXJyb3IpIHtcbiAgICAgICAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwibG9zcGVjLWVycm9yXCI+JHtlc2NhcGVIdG1sKHN0YXRlLmxvc3BlY0Vycm9yKX08L2Rpdj5gO1xuICAgICAgICB9IGVsc2UgaWYgKHN0YXRlLmxvc3BlY1Jlc3VsdCAmJiBzdGF0ZS5jb25maWcubG9zcGVjU2x1Zykge1xuICAgICAgICAgIGh0bWwgKz0gYDxkaXYgY2xhc3M9XCJsb3NwZWMtaW5mb1wiPiR7ZXNjYXBlSHRtbChzdGF0ZS5sb3NwZWNSZXN1bHQubmFtZSl9IFxcdTIwMTQgJHtzdGF0ZS5sb3NwZWNSZXN1bHQubnVtQ29sb3JzfSBjb2xvcnM8L2Rpdj5gO1xuICAgICAgICB9XG4gICAgICB9XG5cbiAgICAgIHJvd0luZGV4Kys7XG4gICAgfVxuICB9XG4gIGxpc3QuaW5uZXJIVE1MID0gaHRtbDtcbn1cblxuLy8gTGlnaHR3ZWlnaHQgcmUtcmVuZGVyOiBqdXN0IHVwZGF0ZSBmb2N1cy9jaGFuZ2VkIGNsYXNzZXMgd2l0aG91dCBkZXN0cm95aW5nIGlucHV0c1xuZnVuY3Rpb24gdXBkYXRlU2V0dGluZ3NGb2N1c09ubHkobGlzdDogSFRNTEVsZW1lbnQpOiB2b2lkIHtcbiAgY29uc3Qgcm93cyA9IGxpc3QucXVlcnlTZWxlY3RvckFsbCgnLnNldHRpbmctcm93Jyk7XG4gIHJvd3MuZm9yRWFjaCgocm93LCBpKSA9PiB7XG4gICAgKHJvdyBhcyBIVE1MRWxlbWVudCkuY2xhc3NMaXN0LnRvZ2dsZSgnZm9jdXNlZCcsIGkgPT09IHN0YXRlLnNldHRpbmdzRm9jdXNJbmRleCk7XG4gIH0pO1xufVxuXG5mdW5jdGlvbiByZW5kZXJQYWxldHRlU3dhdGNoZXMoY29sb3JzOiBzdHJpbmdbXSk6IHN0cmluZyB7XG4gIGxldCBodG1sID0gJzxkaXYgY2xhc3M9XCJwYWxldHRlLXN3YXRjaGVzXCI+JztcbiAgZm9yIChjb25zdCBjb2xvciBvZiBjb2xvcnMpIHtcbiAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwicGFsZXR0ZS1zd2F0Y2hcIiBzdHlsZT1cImJhY2tncm91bmQ6JHtjb2xvcn1cIiB0aXRsZT1cIiR7Y29sb3J9XCI+PC9kaXY+YDtcbiAgfVxuICBodG1sICs9ICc8L2Rpdj4nO1xuICByZXR1cm4gaHRtbDtcbn1cblxuZnVuY3Rpb24gZXNjYXBlSHRtbChzOiBzdHJpbmcpOiBzdHJpbmcge1xuICByZXR1cm4gcy5yZXBsYWNlKC8mL2csICcmYW1wOycpLnJlcGxhY2UoLzwvZywgJyZsdDsnKS5yZXBsYWNlKC8+L2csICcmZ3Q7JykucmVwbGFjZSgvXCIvZywgJyZxdW90OycpO1xufVxuXG5mdW5jdGlvbiByZW5kZXJJbmxpbmVTZWxlY3Qoa2V5OiBzdHJpbmcpOiBzdHJpbmcge1xuICBjb25zdCBjID0gc3RhdGUuY29uZmlnO1xuICBzd2l0Y2ggKGtleSkge1xuICAgIGNhc2UgJ2Rvd25zY2FsZU1vZGUnOiB7XG4gICAgICBjb25zdCBvcHRzID0gRE9XTlNDQUxFX01PREVTLm1hcChtID0+XG4gICAgICAgIGA8b3B0aW9uIHZhbHVlPVwiJHttfVwiJHttID09PSBjLmRvd25zY2FsZU1vZGUgPyAnIHNlbGVjdGVkJyA6ICcnfT4ke219PC9vcHRpb24+YFxuICAgICAgKS5qb2luKCcnKTtcbiAgICAgIHJldHVybiBgPHNlbGVjdCBjbGFzcz1cInNldHRpbmctaW5saW5lLXNlbGVjdFwiIGRhdGEta2V5PVwiJHtrZXl9XCI+JHtvcHRzfTwvc2VsZWN0PmA7XG4gICAgfVxuICAgIGNhc2UgJ3BhbGV0dGVOYW1lJzoge1xuICAgICAgbGV0IG9wdHMgPSBgPG9wdGlvbiB2YWx1ZT1cIlwiJHtjLnBhbGV0dGVOYW1lID09PSBudWxsID8gJyBzZWxlY3RlZCcgOiAnJ30+bm9uZTwvb3B0aW9uPmA7XG4gICAgICBvcHRzICs9IHN0YXRlLnBhbGV0dGVzLm1hcChwID0+XG4gICAgICAgIGA8b3B0aW9uIHZhbHVlPVwiJHtwLnNsdWd9XCIke3Auc2x1ZyA9PT0gYy5wYWxldHRlTmFtZSA/ICcgc2VsZWN0ZWQnIDogJyd9PiR7cC5zbHVnfSAoJHtwLm51bUNvbG9yc30pPC9vcHRpb24+YFxuICAgICAgKS5qb2luKCcnKTtcbiAgICAgIHJldHVybiBgPHNlbGVjdCBjbGFzcz1cInNldHRpbmctaW5saW5lLXNlbGVjdFwiIGRhdGEta2V5PVwiJHtrZXl9XCI+JHtvcHRzfTwvc2VsZWN0PmA7XG4gICAgfVxuICAgIGRlZmF1bHQ6XG4gICAgICByZXR1cm4gJyc7XG4gIH1cbn1cblxuZnVuY3Rpb24gcmVuZGVySW5saW5lSW5wdXQoa2V5OiBzdHJpbmcpOiBzdHJpbmcge1xuICBjb25zdCBjID0gc3RhdGUuY29uZmlnO1xuICBzd2l0Y2ggKGtleSkge1xuICAgIGNhc2UgJ2dyaWRTaXplJzoge1xuICAgICAgY29uc3QgdmFsID0gYy5ncmlkU2l6ZSA9PT0gbnVsbCA/ICcnIDogYy5ncmlkU2l6ZTtcbiAgICAgIHJldHVybiBgPGlucHV0IGNsYXNzPVwic2V0dGluZy1pbmxpbmUtaW5wdXRcIiB0eXBlPVwidGV4dFwiIHZhbHVlPVwiJHt2YWx9XCIgcGxhY2Vob2xkZXI9XCJhdXRvXCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdncmlkUGhhc2VYJzoge1xuICAgICAgY29uc3QgdmFsID0gYy5ncmlkUGhhc2VYID09PSBudWxsID8gJycgOiBjLmdyaWRQaGFzZVg7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0XCIgdHlwZT1cInRleHRcIiB2YWx1ZT1cIiR7dmFsfVwiIHBsYWNlaG9sZGVyPVwiYXV0b1wiIGRhdGEta2V5PVwiJHtrZXl9XCI+YDtcbiAgICB9XG4gICAgY2FzZSAnZ3JpZFBoYXNlWSc6IHtcbiAgICAgIGNvbnN0IHZhbCA9IGMuZ3JpZFBoYXNlWSA9PT0gbnVsbCA/ICcnIDogYy5ncmlkUGhhc2VZO1xuICAgICAgcmV0dXJuIGA8aW5wdXQgY2xhc3M9XCJzZXR0aW5nLWlubGluZS1pbnB1dFwiIHR5cGU9XCJ0ZXh0XCIgdmFsdWU9XCIke3ZhbH1cIiBwbGFjZWhvbGRlcj1cImF1dG9cIiBkYXRhLWtleT1cIiR7a2V5fVwiPmA7XG4gICAgfVxuICAgIGNhc2UgJ21heEdyaWRDYW5kaWRhdGUnOiB7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0XCIgdHlwZT1cInRleHRcIiB2YWx1ZT1cIiR7Yy5tYXhHcmlkQ2FuZGlkYXRlfVwiIGRhdGEta2V5PVwiJHtrZXl9XCI+YDtcbiAgICB9XG4gICAgY2FzZSAnYWFUaHJlc2hvbGQnOiB7XG4gICAgICBjb25zdCB2YWwgPSBjLmFhVGhyZXNob2xkID09PSBudWxsID8gJycgOiBjLmFhVGhyZXNob2xkLnRvRml4ZWQoMik7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0XCIgdHlwZT1cInRleHRcIiB2YWx1ZT1cIiR7dmFsfVwiIHBsYWNlaG9sZGVyPVwib2ZmXCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdhdXRvQ29sb3JzJzoge1xuICAgICAgY29uc3QgdmFsID0gYy5hdXRvQ29sb3JzID09PSBudWxsID8gJycgOiBjLmF1dG9Db2xvcnM7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0XCIgdHlwZT1cInRleHRcIiB2YWx1ZT1cIiR7dmFsfVwiIHBsYWNlaG9sZGVyPVwib2ZmXCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdiZ0NvbG9yJzoge1xuICAgICAgY29uc3QgdmFsID0gYy5iZ0NvbG9yID8/ICcnO1xuICAgICAgcmV0dXJuIGA8aW5wdXQgY2xhc3M9XCJzZXR0aW5nLWlubGluZS1pbnB1dFwiIHR5cGU9XCJ0ZXh0XCIgdmFsdWU9XCIke2VzY2FwZUh0bWwodmFsKX1cIiBwbGFjZWhvbGRlcj1cImF1dG8gKCNSUkdHQkIpXCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdib3JkZXJUaHJlc2hvbGQnOiB7XG4gICAgICBjb25zdCB2YWwgPSBjLmJvcmRlclRocmVzaG9sZCA9PT0gbnVsbCA/ICcnIDogYy5ib3JkZXJUaHJlc2hvbGQudG9GaXhlZCgyKTtcbiAgICAgIHJldHVybiBgPGlucHV0IGNsYXNzPVwic2V0dGluZy1pbmxpbmUtaW5wdXRcIiB0eXBlPVwidGV4dFwiIHZhbHVlPVwiJHt2YWx9XCIgcGxhY2Vob2xkZXI9XCIwLjQwXCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdiZ1RvbGVyYW5jZSc6IHtcbiAgICAgIGNvbnN0IHZhbCA9IGMuYmdUb2xlcmFuY2UudG9GaXhlZCgyKTtcbiAgICAgIHJldHVybiBgPGlucHV0IGNsYXNzPVwic2V0dGluZy1pbmxpbmUtaW5wdXRcIiB0eXBlPVwidGV4dFwiIHZhbHVlPVwiJHt2YWx9XCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdvdXRwdXRTY2FsZSc6IHtcbiAgICAgIGNvbnN0IHZhbCA9IGMub3V0cHV0U2NhbGUgPT09IG51bGwgPyAnJyA6IGMub3V0cHV0U2NhbGU7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0XCIgdHlwZT1cInRleHRcIiB2YWx1ZT1cIiR7dmFsfVwiIHBsYWNlaG9sZGVyPVwib2ZmXCIgZGF0YS1rZXk9XCIke2tleX1cIj5gO1xuICAgIH1cbiAgICBjYXNlICdvdXRwdXRXaWR0aCc6IHtcbiAgICAgIGNvbnN0IHZhbCA9IGMub3V0cHV0V2lkdGggPT09IG51bGwgPyAnJyA6IGMub3V0cHV0V2lkdGg7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0XCIgdHlwZT1cInRleHRcIiB2YWx1ZT1cIiR7dmFsfVwiIHBsYWNlaG9sZGVyPVwiYXV0b1wiIGRhdGEta2V5PVwiJHtrZXl9XCI+YDtcbiAgICB9XG4gICAgY2FzZSAnb3V0cHV0SGVpZ2h0Jzoge1xuICAgICAgY29uc3QgdmFsID0gYy5vdXRwdXRIZWlnaHQgPT09IG51bGwgPyAnJyA6IGMub3V0cHV0SGVpZ2h0O1xuICAgICAgcmV0dXJuIGA8aW5wdXQgY2xhc3M9XCJzZXR0aW5nLWlubGluZS1pbnB1dFwiIHR5cGU9XCJ0ZXh0XCIgdmFsdWU9XCIke3ZhbH1cIiBwbGFjZWhvbGRlcj1cImF1dG9cIiBkYXRhLWtleT1cIiR7a2V5fVwiPmA7XG4gICAgfVxuICAgIGNhc2UgJ2xvc3BlY1NsdWcnOiB7XG4gICAgICBjb25zdCB2YWwgPSBjLmxvc3BlY1NsdWcgPz8gJyc7XG4gICAgICByZXR1cm4gYDxpbnB1dCBjbGFzcz1cInNldHRpbmctaW5saW5lLWlucHV0IHNldHRpbmctaW5saW5lLWlucHV0LXdpZGVcIiB0eXBlPVwidGV4dFwiIHZhbHVlPVwiJHtlc2NhcGVIdG1sKHZhbCl9XCIgcGxhY2Vob2xkZXI9XCJlLmcuIHBpY28tOFwiIGRhdGEta2V5PVwiJHtrZXl9XCI+YDtcbiAgICB9XG4gICAgZGVmYXVsdDpcbiAgICAgIHJldHVybiAnJztcbiAgfVxufVxuXG5mdW5jdGlvbiBzdGFydEVkaXRpbmcoa2V5OiBzdHJpbmcpOiB2b2lkIHtcbiAgLy8gQm9vbGVhbnMgdG9nZ2xlIGltbWVkaWF0ZWx5XG4gIGlmIChCT09MRUFOX1NFVFRJTkdTLmluY2x1ZGVzKGtleSkpIHtcbiAgICBhZGp1c3RTZXR0aW5nKGtleSwgMSk7XG4gICAgcmVuZGVyU2V0dGluZ3MoKTtcbiAgICBhdXRvUHJvY2VzcygpO1xuICAgIHJldHVybjtcbiAgfVxuICAvLyBTZWxlY3RzIGFyZSBhbHdheXMgdmlzaWJsZVxuICBpZiAoU0VMRUNUX1NFVFRJTkdTLmluY2x1ZGVzKGtleSkpIHtcbiAgICByZXR1cm47XG4gIH1cbiAgLy8gRmlsZSBzZXR0aW5ncyBvcGVuIGEgZmlsZSBkaWFsb2dcbiAgaWYgKEZJTEVfU0VUVElOR1MuaW5jbHVkZXMoa2V5KSkge1xuICAgIGlmIChrZXkgPT09ICdwYWxldHRlRmlsZScpIHtcbiAgICAgIGxvYWRQYWxldHRlRmlsZURpYWxvZygpO1xuICAgIH1cbiAgICByZXR1cm47XG4gIH1cbiAgLy8gRm9yIGlubGluZSBpbnB1dHMsIGp1c3QgZm9jdXMgdGhlIGlucHV0IGVsZW1lbnRcbiAgaWYgKElOUFVUX1NFVFRJTkdTLmluY2x1ZGVzKGtleSkpIHtcbiAgICBjb25zdCBpbnB1dCA9IGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3IoYC5zZXR0aW5nLWlubGluZS1pbnB1dFtkYXRhLWtleT1cIiR7a2V5fVwiXWApIGFzIEhUTUxJbnB1dEVsZW1lbnQgfCBudWxsO1xuICAgIGlmIChpbnB1dCkge1xuICAgICAgaW5wdXQuZm9jdXMoKTtcbiAgICAgIGlucHV0LnNlbGVjdCgpO1xuICAgIH1cbiAgICByZXR1cm47XG4gIH1cbn1cblxuZnVuY3Rpb24gY2xlYXJTZXR0aW5nKGtleTogc3RyaW5nKTogdm9pZCB7XG4gIGNvbnN0IGMgPSBzdGF0ZS5jb25maWc7XG4gIHN3aXRjaCAoa2V5KSB7XG4gICAgY2FzZSAnZ3JpZFNpemUnOiBjLmdyaWRTaXplID0gbnVsbDsgYnJlYWs7XG4gICAgY2FzZSAnZ3JpZFBoYXNlWCc6IGMuZ3JpZFBoYXNlWCA9IG51bGw7IGJyZWFrO1xuICAgIGNhc2UgJ2dyaWRQaGFzZVknOiBjLmdyaWRQaGFzZVkgPSBudWxsOyBicmVhaztcbiAgICBjYXNlICdhYVRocmVzaG9sZCc6IGMuYWFUaHJlc2hvbGQgPSBudWxsOyBicmVhaztcbiAgICBjYXNlICdhdXRvQ29sb3JzJzogYy5hdXRvQ29sb3JzID0gbnVsbDsgYnJlYWs7XG4gICAgY2FzZSAnbG9zcGVjU2x1Zyc6XG4gICAgICBjLmxvc3BlY1NsdWcgPSBudWxsO1xuICAgICAgYy5jdXN0b21QYWxldHRlID0gbnVsbDtcbiAgICAgIHN0YXRlLmxvc3BlY1Jlc3VsdCA9IG51bGw7XG4gICAgICBzdGF0ZS5wYWxldHRlQ29sb3JzID0gbnVsbDtcbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ3BhbGV0dGVGaWxlJzpcbiAgICAgIGlmICghYy5sb3NwZWNTbHVnKSB7XG4gICAgICAgIGMuY3VzdG9tUGFsZXR0ZSA9IG51bGw7XG4gICAgICAgIHN0YXRlLnBhbGV0dGVDb2xvcnMgPSBudWxsO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnYmdDb2xvcic6IGMuYmdDb2xvciA9IG51bGw7IGJyZWFrO1xuICAgIGNhc2UgJ2JvcmRlclRocmVzaG9sZCc6IGMuYm9yZGVyVGhyZXNob2xkID0gbnVsbDsgYnJlYWs7XG4gICAgY2FzZSAnb3V0cHV0U2NhbGUnOiBjLm91dHB1dFNjYWxlID0gbnVsbDsgYnJlYWs7XG4gICAgY2FzZSAnb3V0cHV0V2lkdGgnOiBjLm91dHB1dFdpZHRoID0gbnVsbDsgYnJlYWs7XG4gICAgY2FzZSAnb3V0cHV0SGVpZ2h0JzogYy5vdXRwdXRIZWlnaHQgPSBudWxsOyBicmVhaztcbiAgfVxuICByZW5kZXJTZXR0aW5ncygpO1xuICBhdXRvUHJvY2VzcygpO1xufVxuXG5mdW5jdGlvbiBjb21taXRFZGl0KGtleTogc3RyaW5nLCByYXdWYWx1ZTogc3RyaW5nKTogdm9pZCB7XG4gIGNvbnN0IGMgPSBzdGF0ZS5jb25maWc7XG4gIGNvbnN0IHZhbCA9IHJhd1ZhbHVlLnRyaW0oKTtcblxuICBzd2l0Y2ggKGtleSkge1xuICAgIGNhc2UgJ2dyaWRTaXplJzpcbiAgICAgIGlmICh2YWwgPT09ICcnIHx8IHZhbCA9PT0gJ2F1dG8nKSB7XG4gICAgICAgIGMuZ3JpZFNpemUgPSBudWxsO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgY29uc3QgbiA9IHBhcnNlSW50KHZhbCk7XG4gICAgICAgIGlmICghaXNOYU4obikgJiYgbiA+PSAxKSBjLmdyaWRTaXplID0gbjtcbiAgICAgIH1cbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ2dyaWRQaGFzZVgnOlxuICAgICAgaWYgKHZhbCA9PT0gJycgfHwgdmFsID09PSAnYXV0bycpIHtcbiAgICAgICAgYy5ncmlkUGhhc2VYID0gbnVsbDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGNvbnN0IG4gPSBwYXJzZUludCh2YWwpO1xuICAgICAgICBpZiAoIWlzTmFOKG4pICYmIG4gPj0gMCkgYy5ncmlkUGhhc2VYID0gbjtcbiAgICAgIH1cbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ2dyaWRQaGFzZVknOlxuICAgICAgaWYgKHZhbCA9PT0gJycgfHwgdmFsID09PSAnYXV0bycpIHtcbiAgICAgICAgYy5ncmlkUGhhc2VZID0gbnVsbDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGNvbnN0IG4gPSBwYXJzZUludCh2YWwpO1xuICAgICAgICBpZiAoIWlzTmFOKG4pICYmIG4gPj0gMCkgYy5ncmlkUGhhc2VZID0gbjtcbiAgICAgIH1cbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ21heEdyaWRDYW5kaWRhdGUnOiB7XG4gICAgICBjb25zdCBuID0gcGFyc2VJbnQodmFsKTtcbiAgICAgIGlmICghaXNOYU4obikgJiYgbiA+PSAyKSBjLm1heEdyaWRDYW5kaWRhdGUgPSBNYXRoLm1pbig2NCwgbik7XG4gICAgICBicmVhaztcbiAgICB9XG4gICAgY2FzZSAnYWFUaHJlc2hvbGQnOlxuICAgICAgaWYgKHZhbCA9PT0gJycgfHwgdmFsID09PSAnb2ZmJykge1xuICAgICAgICBjLmFhVGhyZXNob2xkID0gbnVsbDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGNvbnN0IG4gPSBwYXJzZUZsb2F0KHZhbCk7XG4gICAgICAgIGlmICghaXNOYU4obikpIGMuYWFUaHJlc2hvbGQgPSBNYXRoLm1heCgwLjAxLCBNYXRoLm1pbigxLjAsIG4pKTtcbiAgICAgIH1cbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ2F1dG9Db2xvcnMnOlxuICAgICAgaWYgKHZhbCA9PT0gJycgfHwgdmFsID09PSAnb2ZmJykge1xuICAgICAgICBjLmF1dG9Db2xvcnMgPSBudWxsO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgY29uc3QgbiA9IHBhcnNlSW50KHZhbCk7XG4gICAgICAgIGlmICghaXNOYU4obikgJiYgbiA+PSAyKSB7XG4gICAgICAgICAgYy5hdXRvQ29sb3JzID0gTWF0aC5taW4oMjU2LCBuKTtcbiAgICAgICAgICBjLnBhbGV0dGVOYW1lID0gbnVsbDtcbiAgICAgICAgICBjLmxvc3BlY1NsdWcgPSBudWxsO1xuICAgICAgICAgIGMuY3VzdG9tUGFsZXR0ZSA9IG51bGw7XG4gICAgICAgICAgc3RhdGUucGFsZXR0ZUNvbG9ycyA9IG51bGw7XG4gICAgICAgICAgc3RhdGUubG9zcGVjUmVzdWx0ID0gbnVsbDtcbiAgICAgICAgfVxuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnYmdDb2xvcic6XG4gICAgICBpZiAodmFsID09PSAnJyB8fCB2YWwgPT09ICdhdXRvJykge1xuICAgICAgICBjLmJnQ29sb3IgPSBudWxsO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgLy8gQWNjZXB0IHdpdGggb3Igd2l0aG91dCAjXG4gICAgICAgIGNvbnN0IGhleCA9IHZhbC5zdGFydHNXaXRoKCcjJykgPyB2YWwgOiAnIycgKyB2YWw7XG4gICAgICAgIGlmICgvXiNbMC05QS1GYS1mXXs2fSQvLnRlc3QoaGV4KSkge1xuICAgICAgICAgIGMuYmdDb2xvciA9IGhleC50b1VwcGVyQ2FzZSgpO1xuICAgICAgICB9XG4gICAgICB9XG4gICAgICBicmVhaztcbiAgICBjYXNlICdib3JkZXJUaHJlc2hvbGQnOlxuICAgICAgaWYgKHZhbCA9PT0gJycgfHwgdmFsID09PSAnYXV0bycpIHtcbiAgICAgICAgYy5ib3JkZXJUaHJlc2hvbGQgPSBudWxsO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgY29uc3QgbiA9IHBhcnNlRmxvYXQodmFsKTtcbiAgICAgICAgaWYgKCFpc05hTihuKSkgYy5ib3JkZXJUaHJlc2hvbGQgPSBNYXRoLm1heCgwLjAxLCBNYXRoLm1pbigxLjAsIG4pKTtcbiAgICAgIH1cbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ2JnVG9sZXJhbmNlJzoge1xuICAgICAgY29uc3QgbiA9IHBhcnNlRmxvYXQodmFsKTtcbiAgICAgIGlmICghaXNOYU4obikpIGMuYmdUb2xlcmFuY2UgPSBNYXRoLm1heCgwLjAxLCBNYXRoLm1pbigwLjUwLCBuKSk7XG4gICAgICBicmVhaztcbiAgICB9XG4gICAgY2FzZSAnZG93bnNjYWxlTW9kZSc6XG4gICAgICBpZiAoRE9XTlNDQUxFX01PREVTLmluY2x1ZGVzKHZhbCkpIGMuZG93bnNjYWxlTW9kZSA9IHZhbDtcbiAgICAgIGJyZWFrO1xuICAgIGNhc2UgJ3BhbGV0dGVOYW1lJzpcbiAgICAgIGMucGFsZXR0ZU5hbWUgPSB2YWwgPT09ICcnID8gbnVsbCA6IHZhbDtcbiAgICAgIGlmIChjLnBhbGV0dGVOYW1lICE9PSBudWxsKSB7XG4gICAgICAgIGMuYXV0b0NvbG9ycyA9IG51bGw7XG4gICAgICAgIGMubG9zcGVjU2x1ZyA9IG51bGw7XG4gICAgICAgIGMuY3VzdG9tUGFsZXR0ZSA9IG51bGw7XG4gICAgICAgIHN0YXRlLmxvc3BlY1Jlc3VsdCA9IG51bGw7XG4gICAgICAgIGZldGNoUGFsZXR0ZUNvbG9ycyhjLnBhbGV0dGVOYW1lKTtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHN0YXRlLnBhbGV0dGVDb2xvcnMgPSBudWxsO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnbG9zcGVjU2x1Zyc6XG4gICAgICAvLyBMb3NwZWM6IGNvbW1pdCB0cmlnZ2VycyBhIGZldGNoXG4gICAgICBpZiAodmFsID09PSAnJyB8fCB2YWwgPT09ICdub25lJykge1xuICAgICAgICBjLmxvc3BlY1NsdWcgPSBudWxsO1xuICAgICAgICBjLmN1c3RvbVBhbGV0dGUgPSBudWxsO1xuICAgICAgICBzdGF0ZS5sb3NwZWNSZXN1bHQgPSBudWxsO1xuICAgICAgICBzdGF0ZS5wYWxldHRlQ29sb3JzID0gbnVsbDtcbiAgICAgICAgcmVuZGVyU2V0dGluZ3MoKTtcbiAgICAgICAgYXV0b1Byb2Nlc3MoKTtcbiAgICAgICAgcmV0dXJuO1xuICAgICAgfVxuICAgICAgZmV0Y2hMb3NwZWModmFsKTtcbiAgICAgIHJldHVybjtcbiAgICBjYXNlICdvdXRwdXRTY2FsZSc6XG4gICAgICBpZiAodmFsID09PSAnJyB8fCB2YWwgPT09ICdvZmYnIHx8IHZhbCA9PT0gJzEnKSB7XG4gICAgICAgIGMub3V0cHV0U2NhbGUgPSBudWxsO1xuICAgICAgfSBlbHNlIHtcbiAgICAgICAgY29uc3QgbiA9IHBhcnNlSW50KHZhbCk7XG4gICAgICAgIGlmICghaXNOYU4obikgJiYgbiA+PSAyICYmIG4gPD0gMTYpIGMub3V0cHV0U2NhbGUgPSBuO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnb3V0cHV0V2lkdGgnOlxuICAgICAgaWYgKHZhbCA9PT0gJycgfHwgdmFsID09PSAnYXV0bycpIHtcbiAgICAgICAgYy5vdXRwdXRXaWR0aCA9IG51bGw7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICBjb25zdCBuID0gcGFyc2VJbnQodmFsKTtcbiAgICAgICAgaWYgKCFpc05hTihuKSAmJiBuID49IDEpIGMub3V0cHV0V2lkdGggPSBuO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gICAgY2FzZSAnb3V0cHV0SGVpZ2h0JzpcbiAgICAgIGlmICh2YWwgPT09ICcnIHx8IHZhbCA9PT0gJ2F1dG8nKSB7XG4gICAgICAgIGMub3V0cHV0SGVpZ2h0ID0gbnVsbDtcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIGNvbnN0IG4gPSBwYXJzZUludCh2YWwpO1xuICAgICAgICBpZiAoIWlzTmFOKG4pICYmIG4gPj0gMSkgYy5vdXRwdXRIZWlnaHQgPSBuO1xuICAgICAgfVxuICAgICAgYnJlYWs7XG4gIH1cblxuICByZW5kZXJTZXR0aW5ncygpO1xuICBhdXRvUHJvY2VzcygpO1xufVxuXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cbi8vIERpYWdub3N0aWNzIHJlbmRlcmluZ1xuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmZ1bmN0aW9uIHJlbmRlckRpYWdub3N0aWNzKCk6IHZvaWQge1xuICBjb25zdCBpbmZvID0gc3RhdGUuaW1hZ2VJbmZvO1xuICBpZiAoIWluZm8pIHtcbiAgICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZGlhZy1ncmlkLWluZm8nKSEuaW5uZXJIVE1MID1cbiAgICAgICc8ZGl2IGNsYXNzPVwiZGlhZy1pdGVtXCI+PHNwYW4gY2xhc3M9XCJsYWJlbFwiPk5vIGltYWdlIGxvYWRlZDwvc3Bhbj48L2Rpdj4nO1xuICAgIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdkaWFnLWdyaWQtYmFycycpIS5pbm5lckhUTUwgPSAnJztcbiAgICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZGlhZy1pbmZvJykhLmlubmVySFRNTCA9ICcnO1xuICAgIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdkaWFnLWhpc3RvZ3JhbScpIS5pbm5lckhUTUwgPSAnJztcbiAgICByZXR1cm47XG4gIH1cblxuICBsZXQgZ3JpZEh0bWwgPSAnJztcbiAgZ3JpZEh0bWwgKz0gYDxkaXYgY2xhc3M9XCJkaWFnLWl0ZW1cIj48c3BhbiBjbGFzcz1cImxhYmVsXCI+RGV0ZWN0ZWQgc2l6ZTwvc3Bhbj48c3BhbiBjbGFzcz1cInZhbHVlXCI+JHtpbmZvLmdyaWRTaXplID8/ICdub25lJ308L3NwYW4+PC9kaXY+YDtcbiAgZ3JpZEh0bWwgKz0gYDxkaXYgY2xhc3M9XCJkaWFnLWl0ZW1cIj48c3BhbiBjbGFzcz1cImxhYmVsXCI+Q29uZmlkZW5jZTwvc3Bhbj48c3BhbiBjbGFzcz1cInZhbHVlXCI+JHtpbmZvLmdyaWRDb25maWRlbmNlICE9IG51bGwgPyAoaW5mby5ncmlkQ29uZmlkZW5jZSAqIDEwMCkudG9GaXhlZCgxKSArICclJyA6ICduL2EnfTwvc3Bhbj48L2Rpdj5gO1xuICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZGlhZy1ncmlkLWluZm8nKSEuaW5uZXJIVE1MID0gZ3JpZEh0bWw7XG5cbiAgbGV0IGJhcnNIdG1sID0gJyc7XG4gIGlmIChpbmZvLmdyaWRTY29yZXMgJiYgaW5mby5ncmlkU2NvcmVzLmxlbmd0aCA+IDApIHtcbiAgICBjb25zdCBtYXhTY29yZSA9IE1hdGgubWF4KC4uLmluZm8uZ3JpZFNjb3Jlcy5tYXAocyA9PiBzWzFdKSk7XG4gICAgY29uc3QgYmVzdFNpemUgPSBpbmZvLmdyaWRTaXplO1xuICAgIGZvciAoY29uc3QgW3NpemUsIHNjb3JlXSBvZiBpbmZvLmdyaWRTY29yZXMpIHtcbiAgICAgIGNvbnN0IHBjdCA9IG1heFNjb3JlID4gMCA/IChzY29yZSAvIG1heFNjb3JlICogMTAwKSA6IDA7XG4gICAgICBjb25zdCBiZXN0ID0gc2l6ZSA9PT0gYmVzdFNpemUgPyAnIGJlc3QnIDogJyc7XG4gICAgICBiYXJzSHRtbCArPSBgPGRpdiBjbGFzcz1cImdyaWQtYmFyLXJvd1wiPmA7XG4gICAgICBiYXJzSHRtbCArPSBgPHNwYW4gY2xhc3M9XCJncmlkLWJhci1sYWJlbFwiPiR7c2l6ZX08L3NwYW4+YDtcbiAgICAgIGJhcnNIdG1sICs9IGA8ZGl2IGNsYXNzPVwiZ3JpZC1iYXItdHJhY2tcIj48ZGl2IGNsYXNzPVwiZ3JpZC1iYXItZmlsbCR7YmVzdH1cIiBzdHlsZT1cIndpZHRoOiR7cGN0fSVcIj48L2Rpdj48L2Rpdj5gO1xuICAgICAgYmFyc0h0bWwgKz0gYDxzcGFuIGNsYXNzPVwiZ3JpZC1iYXItdmFsdWVcIj4ke3Njb3JlLnRvRml4ZWQoMyl9PC9zcGFuPmA7XG4gICAgICBiYXJzSHRtbCArPSBgPC9kaXY+YDtcbiAgICB9XG4gIH1cbiAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2RpYWctZ3JpZC1iYXJzJykhLmlubmVySFRNTCA9IGJhcnNIdG1sO1xuXG4gIGxldCBpbmZvSHRtbCA9ICcnO1xuICBpbmZvSHRtbCArPSBgPGRpdiBjbGFzcz1cImRpYWctaXRlbVwiPjxzcGFuIGNsYXNzPVwibGFiZWxcIj5EaW1lbnNpb25zPC9zcGFuPjxzcGFuIGNsYXNzPVwidmFsdWVcIj4ke2luZm8ud2lkdGh9IHggJHtpbmZvLmhlaWdodH08L3NwYW4+PC9kaXY+YDtcbiAgaW5mb0h0bWwgKz0gYDxkaXYgY2xhc3M9XCJkaWFnLWl0ZW1cIj48c3BhbiBjbGFzcz1cImxhYmVsXCI+VW5pcXVlIGNvbG9yczwvc3Bhbj48c3BhbiBjbGFzcz1cInZhbHVlXCI+JHtpbmZvLnVuaXF1ZUNvbG9yc308L3NwYW4+PC9kaXY+YDtcbiAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2RpYWctaW5mbycpIS5pbm5lckhUTUwgPSBpbmZvSHRtbDtcblxuICBsZXQgaGlzdEh0bWwgPSAnJztcbiAgaWYgKGluZm8uaGlzdG9ncmFtKSB7XG4gICAgZm9yIChjb25zdCBlbnRyeSBvZiBpbmZvLmhpc3RvZ3JhbSkge1xuICAgICAgaGlzdEh0bWwgKz0gYDxkaXYgY2xhc3M9XCJjb2xvci1yb3dcIj5gO1xuICAgICAgaGlzdEh0bWwgKz0gYDxkaXYgY2xhc3M9XCJjb2xvci1zd2F0Y2hcIiBzdHlsZT1cImJhY2tncm91bmQ6JHtlbnRyeS5oZXh9XCI+PC9kaXY+YDtcbiAgICAgIGhpc3RIdG1sICs9IGA8c3BhbiBjbGFzcz1cImNvbG9yLWhleFwiPiR7ZW50cnkuaGV4fTwvc3Bhbj5gO1xuICAgICAgaGlzdEh0bWwgKz0gYDxkaXYgY2xhc3M9XCJjb2xvci1iYXItdHJhY2tcIj48ZGl2IGNsYXNzPVwiY29sb3ItYmFyLWZpbGxcIiBzdHlsZT1cIndpZHRoOiR7TWF0aC5taW4oZW50cnkucGVyY2VudCwgMTAwKX0lO2JhY2tncm91bmQ6JHtlbnRyeS5oZXh9XCI+PC9kaXY+PC9kaXY+YDtcbiAgICAgIGhpc3RIdG1sICs9IGA8c3BhbiBjbGFzcz1cImNvbG9yLXBlcmNlbnRcIj4ke2VudHJ5LnBlcmNlbnQudG9GaXhlZCgxKX0lPC9zcGFuPmA7XG4gICAgICBoaXN0SHRtbCArPSBgPC9kaXY+YDtcbiAgICB9XG4gIH1cbiAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2RpYWctaGlzdG9ncmFtJykhLmlubmVySFRNTCA9IGhpc3RIdG1sO1xufVxuXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cbi8vIEltYWdlIGxvYWRpbmcgYW5kIHByb2Nlc3Npbmdcbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuXG5hc3luYyBmdW5jdGlvbiBsb2FkSW1hZ2VCbG9iKHdoaWNoOiBzdHJpbmcpOiBQcm9taXNlPHN0cmluZz4ge1xuICBjb25zdCBieXRlcyA9IGF3YWl0IGludm9rZTxudW1iZXJbXT4oJ2dldF9pbWFnZScsIHsgd2hpY2ggfSk7XG4gIGNvbnN0IGFyciA9IG5ldyBVaW50OEFycmF5KGJ5dGVzKTtcbiAgY29uc3QgYmxvYiA9IG5ldyBCbG9iKFthcnJdLCB7IHR5cGU6ICdpbWFnZS9wbmcnIH0pO1xuICByZXR1cm4gVVJMLmNyZWF0ZU9iamVjdFVSTChibG9iKTtcbn1cblxuYXN5bmMgZnVuY3Rpb24gb3BlbkltYWdlKHBhdGg6IHN0cmluZyk6IFByb21pc2U8dm9pZD4ge1xuICBzZXRTdGF0dXMoJ0xvYWRpbmcgaW1hZ2UuLi4nLCAncHJvY2Vzc2luZycpO1xuICAvLyBTaG93IHByb21pbmVudCBsb2FkaW5nIG9uIHRoZSB3ZWxjb21lIHNjcmVlbiBpZiBpdCdzIHZpc2libGVcbiAgY29uc3Qgd2FzT25XZWxjb21lID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3dlbGNvbWUnKSEuc3R5bGUuZGlzcGxheSAhPT0gJ25vbmUnO1xuICBpZiAod2FzT25XZWxjb21lKSB7XG4gICAgc2hvd1dlbGNvbWVMb2FkaW5nKCk7XG4gIH1cbiAgdHJ5IHtcbiAgICBjb25zdCBpbmZvID0gYXdhaXQgaW52b2tlPEltYWdlSW5mbz4oJ29wZW5faW1hZ2UnLCB7IHBhdGggfSk7XG4gICAgc3RhdGUuaW1hZ2VMb2FkZWQgPSB0cnVlO1xuICAgIHN0YXRlLmltYWdlUGF0aCA9IHBhdGg7XG4gICAgc3RhdGUuaW1hZ2VJbmZvID0gaW5mbztcbiAgICBzdGF0ZS5jb25maWcgPSBKU09OLnBhcnNlKEpTT04uc3RyaW5naWZ5KERFRkFVTFRfQ09ORklHKSk7XG4gICAgc3RhdGUubG9zcGVjUmVzdWx0ID0gbnVsbDtcbiAgICBzdGF0ZS5sb3NwZWNFcnJvciA9IG51bGw7XG4gICAgc3RhdGUucGFsZXR0ZUNvbG9ycyA9IG51bGw7XG5cbiAgICBjb25zdCBmbmFtZSA9IHBhdGguc3BsaXQoJy8nKS5wb3AoKSEuc3BsaXQoJ1xcXFwnKS5wb3AoKSE7XG4gICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2ZpbGVuYW1lJykhLnRleHRDb250ZW50ID0gZm5hbWU7XG5cbiAgICBoaWRlV2VsY29tZUxvYWRpbmcoKTtcbiAgICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnd2VsY29tZScpIS5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnO1xuICAgIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdvcmlnaW5hbC1wYW5lJykhLnN0eWxlLmRpc3BsYXkgPSAnZmxleCc7XG4gICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Byb2Nlc3NlZC1wYW5lJykhLnN0eWxlLmRpc3BsYXkgPSAnZmxleCc7XG5cbiAgICBjb25zdCBbb3JpZ1VybCwgcHJvY1VybF0gPSBhd2FpdCBQcm9taXNlLmFsbChbXG4gICAgICBsb2FkSW1hZ2VCbG9iKCdvcmlnaW5hbCcpLFxuICAgICAgbG9hZEltYWdlQmxvYigncHJvY2Vzc2VkJyksXG4gICAgXSk7XG4gICAgKGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdvcmlnaW5hbC1pbWcnKSBhcyBIVE1MSW1hZ2VFbGVtZW50KS5zcmMgPSBvcmlnVXJsO1xuICAgIChkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgncHJvY2Vzc2VkLWltZycpIGFzIEhUTUxJbWFnZUVsZW1lbnQpLnNyYyA9IHByb2NVcmw7XG4gICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ29yaWdpbmFsLWRpbXMnKSEudGV4dENvbnRlbnQgPSBgJHtpbmZvLndpZHRofVxcdTAwZDcke2luZm8uaGVpZ2h0fWA7XG4gICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Byb2Nlc3NlZC1kaW1zJykhLnRleHRDb250ZW50ID0gYCR7aW5mby53aWR0aH1cXHUwMGQ3JHtpbmZvLmhlaWdodH1gO1xuXG4gICAgKGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZXR0aW5ncy1wcmV2aWV3LWltZycpIGFzIEhUTUxJbWFnZUVsZW1lbnQpLnNyYyA9IHByb2NVcmw7XG4gICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3NldHRpbmdzLXByZXZpZXctaW1nJykhLnN0eWxlLmRpc3BsYXkgPSAnYmxvY2snO1xuICAgIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZXR0aW5ncy1uby1pbWFnZScpIS5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnO1xuXG4gICAgcmVuZGVyU2V0dGluZ3MoKTtcbiAgICByZW5kZXJEaWFnbm9zdGljcygpO1xuICAgIHNldFN0YXR1cyhgTG9hZGVkIFxcdTIwMTQgJHtpbmZvLndpZHRofVxcdTAwZDcke2luZm8uaGVpZ2h0fSwgZ3JpZD0ke2luZm8uZ3JpZFNpemUgPz8gJ25vbmUnfSwgJHtpbmZvLnVuaXF1ZUNvbG9yc30gY29sb3JzYCwgJ3N1Y2Nlc3MnKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIGhpZGVXZWxjb21lTG9hZGluZygpO1xuICAgIHNldFN0YXR1cygnRXJyb3I6ICcgKyBlLCAnZXJyb3InKTtcbiAgfVxufVxuXG5mdW5jdGlvbiBidWlsZFByb2Nlc3NDb25maWcoKTogUHJvY2Vzc0NvbmZpZyB7XG4gIGNvbnN0IGMgPSBzdGF0ZS5jb25maWc7XG4gIHJldHVybiB7XG4gICAgZ3JpZFNpemU6IGMuZ3JpZFNpemUsXG4gICAgZ3JpZFBoYXNlWDogYy5ncmlkUGhhc2VYLFxuICAgIGdyaWRQaGFzZVk6IGMuZ3JpZFBoYXNlWSxcbiAgICBtYXhHcmlkQ2FuZGlkYXRlOiBjLm1heEdyaWRDYW5kaWRhdGUgPT09IDMyID8gbnVsbCA6IGMubWF4R3JpZENhbmRpZGF0ZSxcbiAgICBub0dyaWREZXRlY3Q6IGMubm9HcmlkRGV0ZWN0LFxuICAgIGRvd25zY2FsZU1vZGU6IGMuZG93bnNjYWxlTW9kZSxcbiAgICBhYVRocmVzaG9sZDogYy5hYVRocmVzaG9sZCxcbiAgICBwYWxldHRlTmFtZTogYy5wYWxldHRlTmFtZSxcbiAgICBhdXRvQ29sb3JzOiBjLmF1dG9Db2xvcnMsXG4gICAgY3VzdG9tUGFsZXR0ZTogYy5jdXN0b21QYWxldHRlLFxuICAgIG5vUXVhbnRpemU6IGMubm9RdWFudGl6ZSxcbiAgICByZW1vdmVCZzogYy5yZW1vdmVCZyxcbiAgICBiZ0NvbG9yOiBjLmJnQ29sb3IsXG4gICAgYm9yZGVyVGhyZXNob2xkOiBjLmJvcmRlclRocmVzaG9sZCxcbiAgICBiZ1RvbGVyYW5jZTogYy5iZ1RvbGVyYW5jZSxcbiAgICBmbG9vZEZpbGw6IGMuZmxvb2RGaWxsLFxuICAgIG91dHB1dFNjYWxlOiBjLm91dHB1dFNjYWxlLFxuICAgIG91dHB1dFdpZHRoOiBjLm91dHB1dFdpZHRoLFxuICAgIG91dHB1dEhlaWdodDogYy5vdXRwdXRIZWlnaHQsXG4gIH07XG59XG5cbmFzeW5jIGZ1bmN0aW9uIHByb2Nlc3NJbWFnZSgpOiBQcm9taXNlPHZvaWQ+IHtcbiAgaWYgKCFzdGF0ZS5pbWFnZUxvYWRlZCB8fCBzdGF0ZS5wcm9jZXNzaW5nKSByZXR1cm47XG4gIHN0YXRlLnByb2Nlc3NpbmcgPSB0cnVlO1xuICBzZXRTdGF0dXMoJ1Byb2Nlc3NpbmcuLi4nLCAncHJvY2Vzc2luZycpO1xuICBjb25zdCB0MCA9IHBlcmZvcm1hbmNlLm5vdygpO1xuICB0cnkge1xuICAgIGNvbnN0IHJlc3VsdCA9IGF3YWl0IGludm9rZTxQcm9jZXNzUmVzdWx0PigncHJvY2VzcycsIHsgcGM6IGJ1aWxkUHJvY2Vzc0NvbmZpZygpIH0pO1xuICAgIHN0YXRlLmltYWdlSW5mbyA9IHsgLi4uc3RhdGUuaW1hZ2VJbmZvISwgLi4ucmVzdWx0IH07XG5cbiAgICBjb25zdCBwcm9jVXJsID0gYXdhaXQgbG9hZEltYWdlQmxvYigncHJvY2Vzc2VkJyk7XG4gICAgKGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdwcm9jZXNzZWQtaW1nJykgYXMgSFRNTEltYWdlRWxlbWVudCkuc3JjID0gcHJvY1VybDtcbiAgICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgncHJvY2Vzc2VkLWRpbXMnKSEudGV4dENvbnRlbnQgPSBgJHtyZXN1bHQud2lkdGh9XFx1MDBkNyR7cmVzdWx0LmhlaWdodH1gO1xuICAgIChkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc2V0dGluZ3MtcHJldmlldy1pbWcnKSBhcyBIVE1MSW1hZ2VFbGVtZW50KS5zcmMgPSBwcm9jVXJsO1xuXG4gICAgcmVuZGVyRGlhZ25vc3RpY3MoKTtcbiAgICBjb25zdCBlbGFwc2VkID0gKChwZXJmb3JtYW5jZS5ub3coKSAtIHQwKSAvIDEwMDApLnRvRml4ZWQoMik7XG4gICAgc3RhdGUubGFzdFByb2Nlc3NUaW1lID0gcGVyZm9ybWFuY2Uubm93KCkgLSB0MDtcbiAgICBzZXRTdGF0dXMoYFByb2Nlc3NlZCBcXHUyMDE0ICR7cmVzdWx0LndpZHRofVxcdTAwZDcke3Jlc3VsdC5oZWlnaHR9LCAke3Jlc3VsdC51bmlxdWVDb2xvcnN9IGNvbG9ycyAoJHtlbGFwc2VkfXMpYCwgJ3N1Y2Nlc3MnKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIHNldFN0YXR1cygnRXJyb3I6ICcgKyBlLCAnZXJyb3InKTtcbiAgfSBmaW5hbGx5IHtcbiAgICBzdGF0ZS5wcm9jZXNzaW5nID0gZmFsc2U7XG4gIH1cbn1cblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBGaWxlIGRpYWxvZ3Ncbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuXG5hc3luYyBmdW5jdGlvbiBkb09wZW4oKTogUHJvbWlzZTx2b2lkPiB7XG4gIHRyeSB7XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgb3BlbkRpYWxvZyh7XG4gICAgICBtdWx0aXBsZTogZmFsc2UsXG4gICAgICBmaWx0ZXJzOiBbe1xuICAgICAgICBuYW1lOiAnSW1hZ2VzJyxcbiAgICAgICAgZXh0ZW5zaW9uczogWydwbmcnLCAnanBnJywgJ2pwZWcnLCAnZ2lmJywgJ3dlYnAnLCAnYm1wJ10sXG4gICAgICB9XSxcbiAgICB9KTtcbiAgICBpZiAocmVzdWx0KSB7XG4gICAgICBhd2FpdCBvcGVuSW1hZ2UocmVzdWx0KTtcbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBzZXRTdGF0dXMoJ0Vycm9yOiAnICsgZSwgJ2Vycm9yJyk7XG4gIH1cbn1cblxuYXN5bmMgZnVuY3Rpb24gZG9TYXZlKCk6IFByb21pc2U8dm9pZD4ge1xuICBpZiAoIXN0YXRlLmltYWdlTG9hZGVkKSByZXR1cm47XG4gIHRyeSB7XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgc2F2ZURpYWxvZyh7XG4gICAgICBkZWZhdWx0UGF0aDogc3RhdGUuaW1hZ2VQYXRoID8gc3RhdGUuaW1hZ2VQYXRoLnJlcGxhY2UoL1xcLlteLl0rJC8sICdfZml4ZWQucG5nJykgOiAnb3V0cHV0LnBuZycsXG4gICAgICBmaWx0ZXJzOiBbe1xuICAgICAgICBuYW1lOiAnUE5HIEltYWdlJyxcbiAgICAgICAgZXh0ZW5zaW9uczogWydwbmcnXSxcbiAgICAgIH1dLFxuICAgIH0pO1xuICAgIGlmIChyZXN1bHQpIHtcbiAgICAgIGF3YWl0IGludm9rZSgnc2F2ZV9pbWFnZScsIHsgcGF0aDogcmVzdWx0IH0pO1xuICAgICAgc2V0U3RhdHVzKCdTYXZlZDogJyArIHJlc3VsdC5zcGxpdCgnLycpLnBvcCgpIS5zcGxpdCgnXFxcXCcpLnBvcCgpISwgJ3N1Y2Nlc3MnKTtcbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBzZXRTdGF0dXMoJ0Vycm9yOiAnICsgZSwgJ2Vycm9yJyk7XG4gIH1cbn1cblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBLZXlib2FyZCBoYW5kbGluZ1xuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmRvY3VtZW50LmFkZEV2ZW50TGlzdGVuZXIoJ2tleWRvd24nLCAoZTogS2V5Ym9hcmRFdmVudCkgPT4ge1xuICAvLyBXaGVuIGZvY3VzZWQgb24gYW4gYWx3YXlzLXZpc2libGUgaW5saW5lIGlucHV0LCBoYW5kbGUgRW50ZXIvRXNjYXBlL1RhYlxuICBpZiAoKGUudGFyZ2V0IGFzIEhUTUxFbGVtZW50KS5jbGFzc0xpc3Q/LmNvbnRhaW5zKCdzZXR0aW5nLWlubGluZS1pbnB1dCcpKSB7XG4gICAgaWYgKGUua2V5ID09PSAnRW50ZXInKSB7XG4gICAgICBlLnByZXZlbnREZWZhdWx0KCk7XG4gICAgICBjb25zdCB0YXJnZXQgPSBlLnRhcmdldCBhcyBIVE1MSW5wdXRFbGVtZW50O1xuICAgICAgY29tbWl0RWRpdCh0YXJnZXQuZGF0YXNldC5rZXkhLCB0YXJnZXQudmFsdWUpO1xuICAgICAgdGFyZ2V0LmJsdXIoKTtcbiAgICB9IGVsc2UgaWYgKGUua2V5ID09PSAnRXNjYXBlJykge1xuICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpO1xuICAgICAgKGUudGFyZ2V0IGFzIEhUTUxJbnB1dEVsZW1lbnQpLmJsdXIoKTtcbiAgICAgIHJlbmRlclNldHRpbmdzKCk7XG4gICAgfSBlbHNlIGlmIChlLmtleSA9PT0gJ1RhYicpIHtcbiAgICAgIGUucHJldmVudERlZmF1bHQoKTtcbiAgICAgIChlLnRhcmdldCBhcyBIVE1MSW5wdXRFbGVtZW50KS5ibHVyKCk7XG4gICAgICBjeWNsZVRhYihlLnNoaWZ0S2V5ID8gLTEgOiAxKTtcbiAgICB9XG4gICAgcmV0dXJuO1xuICB9XG5cbiAgLy8gV2hlbiBmb2N1c2VkIG9uIGFuIGlubGluZSBzZWxlY3QsIGxldCBpdCBoYW5kbGUgaXRzIG93biBrZXlzIGV4Y2VwdCBUYWJcbiAgaWYgKChlLnRhcmdldCBhcyBIVE1MRWxlbWVudCkuY2xhc3NMaXN0Py5jb250YWlucygnc2V0dGluZy1pbmxpbmUtc2VsZWN0JykpIHtcbiAgICBpZiAoZS5rZXkgPT09ICdUYWInKSB7XG4gICAgICBlLnByZXZlbnREZWZhdWx0KCk7XG4gICAgICBjeWNsZVRhYihlLnNoaWZ0S2V5ID8gLTEgOiAxKTtcbiAgICB9XG4gICAgcmV0dXJuO1xuICB9XG5cbiAgLy8gSWdub3JlIG90aGVyIHR5cGluZyBpbiBpbnB1dHMgKHNoZWV0IGlucHV0cywgZXRjLilcbiAgY29uc3QgdGFnID0gKGUudGFyZ2V0IGFzIEhUTUxFbGVtZW50KS50YWdOYW1lO1xuICBpZiAodGFnID09PSAnSU5QVVQnIHx8IHRhZyA9PT0gJ1RFWFRBUkVBJykge1xuICAgIC8vIFN0aWxsIGFsbG93IFRhYiB0byBzd2l0Y2ggdGFicyBmcm9tIGFueSBpbnB1dFxuICAgIGlmIChlLmtleSA9PT0gJ1RhYicpIHsgZS5wcmV2ZW50RGVmYXVsdCgpOyBjeWNsZVRhYihlLnNoaWZ0S2V5ID8gLTEgOiAxKTsgfVxuICAgIHJldHVybjtcbiAgfVxuXG4gIGNvbnN0IGtleSA9IGUua2V5O1xuXG4gIC8vIFRhYiBzd2l0Y2hpbmdcbiAgaWYgKGtleSA9PT0gJ1RhYicpIHsgZS5wcmV2ZW50RGVmYXVsdCgpOyBjeWNsZVRhYihlLnNoaWZ0S2V5ID8gLTEgOiAxKTsgcmV0dXJuOyB9XG5cbiAgLy8gR2xvYmFsIHNob3J0Y3V0c1xuICBpZiAoa2V5ID09PSAnbycpIHsgZG9PcGVuKCk7IHJldHVybjsgfVxuICBpZiAoa2V5ID09PSAncycpIHsgZG9TYXZlKCk7IHJldHVybjsgfVxuICBpZiAoa2V5ID09PSAnICcpIHsgZS5wcmV2ZW50RGVmYXVsdCgpOyBwcm9jZXNzSW1hZ2UoKTsgcmV0dXJuOyB9XG4gIGlmIChrZXkgPT09ICdyJykgeyByZXNldENvbmZpZygpOyByZXR1cm47IH1cbiAgaWYgKChlLmN0cmxLZXkgfHwgZS5tZXRhS2V5KSAmJiBrZXkgPT09ICdxJykgeyB3aW5kb3cuY2xvc2UoKTsgcmV0dXJuOyB9XG5cbiAgLy8gU2V0dGluZ3MgbmF2aWdhdGlvbiAob25seSBvbiBzZXR0aW5ncyB0YWIsIGJsb2NrZWQgZHVyaW5nIHByb2Nlc3NpbmcpXG4gIGlmIChzdGF0ZS5hY3RpdmVUYWIgPT09ICdzZXR0aW5ncycgJiYgIXN0YXRlLnByb2Nlc3NpbmcpIHtcbiAgICBjb25zdCByb3dzID0gZ2V0U2V0dGluZ1Jvd3MoKTtcbiAgICBpZiAoa2V5ID09PSAnaicgfHwga2V5ID09PSAnQXJyb3dEb3duJykge1xuICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpO1xuICAgICAgc3RhdGUuc2V0dGluZ3NGb2N1c0luZGV4ID0gTWF0aC5taW4oc3RhdGUuc2V0dGluZ3NGb2N1c0luZGV4ICsgMSwgcm93cy5sZW5ndGggLSAxKTtcbiAgICAgIHJlbmRlclNldHRpbmdzKCk7XG4gICAgICByZXR1cm47XG4gICAgfVxuICAgIGlmIChrZXkgPT09ICdrJyB8fCBrZXkgPT09ICdBcnJvd1VwJykge1xuICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpO1xuICAgICAgc3RhdGUuc2V0dGluZ3NGb2N1c0luZGV4ID0gTWF0aC5tYXgoc3RhdGUuc2V0dGluZ3NGb2N1c0luZGV4IC0gMSwgMCk7XG4gICAgICByZW5kZXJTZXR0aW5ncygpO1xuICAgICAgcmV0dXJuO1xuICAgIH1cbiAgICBpZiAoa2V5ID09PSAnRW50ZXInKSB7XG4gICAgICBlLnByZXZlbnREZWZhdWx0KCk7XG4gICAgICBjb25zdCByb3cgPSByb3dzW3N0YXRlLnNldHRpbmdzRm9jdXNJbmRleF07XG4gICAgICBpZiAocm93KSBzdGFydEVkaXRpbmcocm93LmtleSk7XG4gICAgICByZXR1cm47XG4gICAgfVxuICAgIGlmIChrZXkgPT09ICdFc2NhcGUnKSB7XG4gICAgICBlLnByZXZlbnREZWZhdWx0KCk7XG4gICAgICBzd2l0Y2hUYWIoJ3ByZXZpZXcnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgaWYgKGtleSA9PT0gJ2wnIHx8IGtleSA9PT0gJ0Fycm93UmlnaHQnKSB7XG4gICAgICBlLnByZXZlbnREZWZhdWx0KCk7XG4gICAgICBjb25zdCByb3cgPSByb3dzW3N0YXRlLnNldHRpbmdzRm9jdXNJbmRleF07XG4gICAgICBpZiAocm93KSB7XG4gICAgICAgIGFkanVzdFNldHRpbmcocm93LmtleSwgMSk7XG4gICAgICAgIHJlbmRlclNldHRpbmdzKCk7XG4gICAgICAgIGF1dG9Qcm9jZXNzKCk7XG4gICAgICB9XG4gICAgICByZXR1cm47XG4gICAgfVxuICAgIGlmIChrZXkgPT09ICdoJyB8fCBrZXkgPT09ICdBcnJvd0xlZnQnKSB7XG4gICAgICBlLnByZXZlbnREZWZhdWx0KCk7XG4gICAgICBjb25zdCByb3cgPSByb3dzW3N0YXRlLnNldHRpbmdzRm9jdXNJbmRleF07XG4gICAgICBpZiAocm93KSB7XG4gICAgICAgIGFkanVzdFNldHRpbmcocm93LmtleSwgLTEpO1xuICAgICAgICByZW5kZXJTZXR0aW5ncygpO1xuICAgICAgICBhdXRvUHJvY2VzcygpO1xuICAgICAgfVxuICAgICAgcmV0dXJuO1xuICAgIH1cbiAgfVxufSk7XG5cbmNvbnN0IFRBQlMgPSBbJ3ByZXZpZXcnLCAnc2V0dGluZ3MnLCAnZGlhZ25vc3RpY3MnLCAnYmF0Y2gnLCAnc2hlZXQnXTtcblxuZnVuY3Rpb24gY3ljbGVUYWIoZGlyOiBudW1iZXIpOiB2b2lkIHtcbiAgbGV0IGlkeCA9IFRBQlMuaW5kZXhPZihzdGF0ZS5hY3RpdmVUYWIpO1xuICBpZHggPSAoaWR4ICsgZGlyICsgVEFCUy5sZW5ndGgpICUgVEFCUy5sZW5ndGg7XG4gIHN3aXRjaFRhYihUQUJTW2lkeF0pO1xufVxuXG5mdW5jdGlvbiByZXNldENvbmZpZygpOiB2b2lkIHtcbiAgc3RhdGUuY29uZmlnID0gSlNPTi5wYXJzZShKU09OLnN0cmluZ2lmeShERUZBVUxUX0NPTkZJRykpO1xuICBzdGF0ZS5sb3NwZWNSZXN1bHQgPSBudWxsO1xuICBzdGF0ZS5sb3NwZWNFcnJvciA9IG51bGw7XG4gIHN0YXRlLnBhbGV0dGVDb2xvcnMgPSBudWxsO1xuICByZW5kZXJTZXR0aW5ncygpO1xuICBpZiAoc3RhdGUuaW1hZ2VMb2FkZWQpIHtcbiAgICBhdXRvUHJvY2VzcygpO1xuICB9XG4gIHNldFN0YXR1cygnQ29uZmlnIHJlc2V0IHRvIGRlZmF1bHRzJyk7XG59XG5cbi8vIEF1dG8tcHJvY2VzcyB3aXRoIGRlYm91bmNlXG5sZXQgcHJvY2Vzc1RpbWVyOiBSZXR1cm5UeXBlPHR5cGVvZiBzZXRUaW1lb3V0PiB8IG51bGwgPSBudWxsO1xuZnVuY3Rpb24gYXV0b1Byb2Nlc3MoKTogdm9pZCB7XG4gIGlmICghc3RhdGUuaW1hZ2VMb2FkZWQpIHJldHVybjtcbiAgaWYgKHByb2Nlc3NUaW1lcikgY2xlYXJUaW1lb3V0KHByb2Nlc3NUaW1lcik7XG4gIHByb2Nlc3NUaW1lciA9IHNldFRpbWVvdXQoKCkgPT4gcHJvY2Vzc0ltYWdlKCksIDE1MCk7XG59XG5cbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuLy8gQmF0Y2ggdGFiXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuZnVuY3Rpb24gcmVuZGVyQmF0Y2goKTogdm9pZCB7XG4gIGNvbnN0IGVsID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2JhdGNoLWNvbnRlbnQnKSE7XG4gIGxldCBodG1sID0gJyc7XG5cbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cImJhdGNoLXNlY3Rpb25cIj4nO1xuICBodG1sICs9ICc8ZGl2IGNsYXNzPVwiYmF0Y2gtdGl0bGVcIj5CYXRjaCBQcm9jZXNzaW5nPC9kaXY+JztcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cImJhdGNoLWRlc2NcIj5Qcm9jZXNzIG11bHRpcGxlIGltYWdlcyB3aXRoIHRoZSBjdXJyZW50IHBpcGVsaW5lIHNldHRpbmdzLjwvZGl2Pic7XG4gIGh0bWwgKz0gJzwvZGl2Pic7XG5cbiAgLy8gRmlsZSBsaXN0XG4gIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJiYXRjaC1zZWN0aW9uXCI+JztcbiAgaHRtbCArPSBgPGRpdiBjbGFzcz1cImJhdGNoLXJvd1wiPjxzcGFuIGNsYXNzPVwiYmF0Y2gtbGFiZWxcIj5GaWxlczwvc3Bhbj48c3BhbiBjbGFzcz1cImJhdGNoLXZhbHVlXCI+JHtzdGF0ZS5iYXRjaEZpbGVzLmxlbmd0aH0gc2VsZWN0ZWQ8L3NwYW4+YDtcbiAgaHRtbCArPSBgPGJ1dHRvbiBjbGFzcz1cImJhdGNoLWJ0blwiIGlkPVwiYmF0Y2gtYWRkLWZpbGVzXCIke3N0YXRlLmJhdGNoUnVubmluZyA/ICcgZGlzYWJsZWQnIDogJyd9PkFkZCBGaWxlczwvYnV0dG9uPmA7XG4gIGlmIChzdGF0ZS5iYXRjaEZpbGVzLmxlbmd0aCA+IDApIHtcbiAgICBodG1sICs9IGA8YnV0dG9uIGNsYXNzPVwiYmF0Y2gtYnRuIGJhdGNoLWJ0bi1kaW1cIiBpZD1cImJhdGNoLWNsZWFyLWZpbGVzXCIke3N0YXRlLmJhdGNoUnVubmluZyA/ICcgZGlzYWJsZWQnIDogJyd9PkNsZWFyPC9idXR0b24+YDtcbiAgfVxuICBodG1sICs9ICc8L2Rpdj4nO1xuXG4gIGlmIChzdGF0ZS5iYXRjaEZpbGVzLmxlbmd0aCA+IDApIHtcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwiYmF0Y2gtZmlsZS1saXN0XCI+JztcbiAgICBmb3IgKGNvbnN0IGYgb2Ygc3RhdGUuYmF0Y2hGaWxlcykge1xuICAgICAgY29uc3QgbmFtZSA9IGYuc3BsaXQoJy8nKS5wb3AoKSEuc3BsaXQoJ1xcXFwnKS5wb3AoKSE7XG4gICAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwiYmF0Y2gtZmlsZVwiPiR7ZXNjYXBlSHRtbChuYW1lKX08L2Rpdj5gO1xuICAgIH1cbiAgICBodG1sICs9ICc8L2Rpdj4nO1xuICB9XG4gIGh0bWwgKz0gJzwvZGl2Pic7XG5cbiAgLy8gT3V0cHV0IGRpcmVjdG9yeVxuICBodG1sICs9ICc8ZGl2IGNsYXNzPVwiYmF0Y2gtc2VjdGlvblwiPic7XG4gIGh0bWwgKz0gYDxkaXYgY2xhc3M9XCJiYXRjaC1yb3dcIj48c3BhbiBjbGFzcz1cImJhdGNoLWxhYmVsXCI+T3V0cHV0PC9zcGFuPjxzcGFuIGNsYXNzPVwiYmF0Y2gtdmFsdWVcIj4ke3N0YXRlLmJhdGNoT3V0cHV0RGlyID8gZXNjYXBlSHRtbChzdGF0ZS5iYXRjaE91dHB1dERpci5zcGxpdCgnLycpLnBvcCgpIS5zcGxpdCgnXFxcXCcpLnBvcCgpISkgOiAnbm90IHNldCd9PC9zcGFuPmA7XG4gIGh0bWwgKz0gYDxidXR0b24gY2xhc3M9XCJiYXRjaC1idG5cIiBpZD1cImJhdGNoLWNob29zZS1kaXJcIiR7c3RhdGUuYmF0Y2hSdW5uaW5nID8gJyBkaXNhYmxlZCcgOiAnJ30+Q2hvb3NlIEZvbGRlcjwvYnV0dG9uPmA7XG4gIGh0bWwgKz0gJzwvZGl2Pic7XG4gIGh0bWwgKz0gJzwvZGl2Pic7XG5cbiAgLy8gUnVuIGJ1dHRvblxuICBjb25zdCBjYW5SdW4gPSBzdGF0ZS5iYXRjaEZpbGVzLmxlbmd0aCA+IDAgJiYgc3RhdGUuYmF0Y2hPdXRwdXREaXIgJiYgIXN0YXRlLmJhdGNoUnVubmluZztcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cImJhdGNoLXNlY3Rpb25cIj4nO1xuICBodG1sICs9IGA8YnV0dG9uIGNsYXNzPVwiYmF0Y2gtYnRuIGJhdGNoLWJ0bi1wcmltYXJ5XCIgaWQ9XCJiYXRjaC1ydW5cIiR7Y2FuUnVuID8gJycgOiAnIGRpc2FibGVkJ30+UHJvY2VzcyBBbGw8L2J1dHRvbj5gO1xuICBodG1sICs9ICc8L2Rpdj4nO1xuXG4gIC8vIFByb2dyZXNzXG4gIGlmIChzdGF0ZS5iYXRjaFByb2dyZXNzKSB7XG4gICAgY29uc3QgcGN0ID0gTWF0aC5yb3VuZCgoc3RhdGUuYmF0Y2hQcm9ncmVzcy5jdXJyZW50IC8gc3RhdGUuYmF0Y2hQcm9ncmVzcy50b3RhbCkgKiAxMDApO1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJiYXRjaC1zZWN0aW9uXCI+JztcbiAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwiYmF0Y2gtcHJvZ3Jlc3MtaW5mb1wiPiR7c3RhdGUuYmF0Y2hQcm9ncmVzcy5jdXJyZW50fS8ke3N0YXRlLmJhdGNoUHJvZ3Jlc3MudG90YWx9ICZtZGFzaDsgJHtlc2NhcGVIdG1sKHN0YXRlLmJhdGNoUHJvZ3Jlc3MuZmlsZW5hbWUpfTwvZGl2PmA7XG4gICAgaHRtbCArPSBgPGRpdiBjbGFzcz1cImJhdGNoLXByb2dyZXNzLWJhclwiPjxkaXYgY2xhc3M9XCJiYXRjaC1wcm9ncmVzcy1maWxsXCIgc3R5bGU9XCJ3aWR0aDoke3BjdH0lXCI+PC9kaXY+PC9kaXY+YDtcbiAgICBodG1sICs9ICc8L2Rpdj4nO1xuICB9XG5cbiAgLy8gUmVzdWx0c1xuICBpZiAoc3RhdGUuYmF0Y2hSZXN1bHQpIHtcbiAgICBjb25zdCByID0gc3RhdGUuYmF0Y2hSZXN1bHQ7XG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cImJhdGNoLXNlY3Rpb25cIj4nO1xuICAgIGh0bWwgKz0gYDxkaXYgY2xhc3M9XCJiYXRjaC1yZXN1bHQtc3VtbWFyeVwiPiR7ci5zdWNjZWVkZWR9IHN1Y2NlZWRlZGA7XG4gICAgaWYgKHIuZmFpbGVkLmxlbmd0aCA+IDApIHtcbiAgICAgIGh0bWwgKz0gYCwgPHNwYW4gY2xhc3M9XCJiYXRjaC1yZXN1bHQtZmFpbGVkXCI+JHtyLmZhaWxlZC5sZW5ndGh9IGZhaWxlZDwvc3Bhbj5gO1xuICAgIH1cbiAgICBodG1sICs9ICc8L2Rpdj4nO1xuICAgIGlmIChyLmZhaWxlZC5sZW5ndGggPiAwKSB7XG4gICAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwiYmF0Y2gtZXJyb3JzXCI+JztcbiAgICAgIGZvciAoY29uc3QgZiBvZiByLmZhaWxlZCkge1xuICAgICAgICBjb25zdCBuYW1lID0gZi5wYXRoLnNwbGl0KCcvJykucG9wKCkhLnNwbGl0KCdcXFxcJykucG9wKCkhO1xuICAgICAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwiYmF0Y2gtZXJyb3JcIj4ke2VzY2FwZUh0bWwobmFtZSl9OiAke2VzY2FwZUh0bWwoZi5lcnJvcil9PC9kaXY+YDtcbiAgICAgIH1cbiAgICAgIGh0bWwgKz0gJzwvZGl2Pic7XG4gICAgfVxuICAgIGh0bWwgKz0gJzwvZGl2Pic7XG4gIH1cblxuICBlbC5pbm5lckhUTUwgPSBodG1sO1xufVxuXG5hc3luYyBmdW5jdGlvbiBiYXRjaEFkZEZpbGVzKCk6IFByb21pc2U8dm9pZD4ge1xuICB0cnkge1xuICAgIGNvbnN0IHJlc3VsdCA9IGF3YWl0IG9wZW5EaWFsb2coe1xuICAgICAgbXVsdGlwbGU6IHRydWUsXG4gICAgICBmaWx0ZXJzOiBbe1xuICAgICAgICBuYW1lOiAnSW1hZ2VzJyxcbiAgICAgICAgZXh0ZW5zaW9uczogWydwbmcnLCAnanBnJywgJ2pwZWcnLCAnZ2lmJywgJ3dlYnAnLCAnYm1wJ10sXG4gICAgICB9XSxcbiAgICB9KTtcbiAgICBpZiAocmVzdWx0KSB7XG4gICAgICAvLyByZXN1bHQgbWF5IGJlIGEgc3RyaW5nIG9yIGFycmF5IGRlcGVuZGluZyBvbiBzZWxlY3Rpb25cbiAgICAgIGNvbnN0IHBhdGhzID0gQXJyYXkuaXNBcnJheShyZXN1bHQpID8gcmVzdWx0IDogW3Jlc3VsdF07XG4gICAgICAvLyBBZGQgdG8gZXhpc3RpbmcgbGlzdCwgZGVkdXBcbiAgICAgIGNvbnN0IGV4aXN0aW5nID0gbmV3IFNldChzdGF0ZS5iYXRjaEZpbGVzKTtcbiAgICAgIGZvciAoY29uc3QgcCBvZiBwYXRocykge1xuICAgICAgICBpZiAocCAmJiAhZXhpc3RpbmcuaGFzKHApKSB7XG4gICAgICAgICAgc3RhdGUuYmF0Y2hGaWxlcy5wdXNoKHApO1xuICAgICAgICAgIGV4aXN0aW5nLmFkZChwKTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgICAgcmVuZGVyQmF0Y2goKTtcbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBzZXRTdGF0dXMoJ0Vycm9yOiAnICsgZSwgJ2Vycm9yJyk7XG4gIH1cbn1cblxuYXN5bmMgZnVuY3Rpb24gYmF0Y2hDaG9vc2VEaXIoKTogUHJvbWlzZTx2b2lkPiB7XG4gIHRyeSB7XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgb3BlbkRpYWxvZyh7XG4gICAgICBkaXJlY3Rvcnk6IHRydWUsXG4gICAgfSk7XG4gICAgaWYgKHJlc3VsdCkge1xuICAgICAgc3RhdGUuYmF0Y2hPdXRwdXREaXIgPSBBcnJheS5pc0FycmF5KHJlc3VsdCkgPyByZXN1bHRbMF0gOiByZXN1bHQ7XG4gICAgICByZW5kZXJCYXRjaCgpO1xuICAgIH1cbiAgfSBjYXRjaCAoZSkge1xuICAgIHNldFN0YXR1cygnRXJyb3I6ICcgKyBlLCAnZXJyb3InKTtcbiAgfVxufVxuXG5hc3luYyBmdW5jdGlvbiBiYXRjaFJ1bigpOiBQcm9taXNlPHZvaWQ+IHtcbiAgaWYgKHN0YXRlLmJhdGNoUnVubmluZyB8fCBzdGF0ZS5iYXRjaEZpbGVzLmxlbmd0aCA9PT0gMCB8fCAhc3RhdGUuYmF0Y2hPdXRwdXREaXIpIHJldHVybjtcbiAgc3RhdGUuYmF0Y2hSdW5uaW5nID0gdHJ1ZTtcbiAgc3RhdGUuYmF0Y2hSZXN1bHQgPSBudWxsO1xuICBzdGF0ZS5iYXRjaFByb2dyZXNzID0geyBjdXJyZW50OiAwLCB0b3RhbDogc3RhdGUuYmF0Y2hGaWxlcy5sZW5ndGgsIGZpbGVuYW1lOiAnJyB9O1xuICByZW5kZXJCYXRjaCgpO1xuICBzZXRTdGF0dXMoJ0JhdGNoIHByb2Nlc3NpbmcuLi4nLCAncHJvY2Vzc2luZycpO1xuXG4gIC8vIExpc3RlbiBmb3IgcHJvZ3Jlc3MgZXZlbnRzXG4gIGNvbnN0IHVubGlzdGVuID0gYXdhaXQgd2luZG93Ll9fVEFVUklfXy5ldmVudC5saXN0ZW4oJ2JhdGNoLXByb2dyZXNzJywgKGV2ZW50OiB7IHBheWxvYWQ6IHsgY3VycmVudDogbnVtYmVyOyB0b3RhbDogbnVtYmVyOyBmaWxlbmFtZTogc3RyaW5nIH0gfSkgPT4ge1xuICAgIHN0YXRlLmJhdGNoUHJvZ3Jlc3MgPSBldmVudC5wYXlsb2FkO1xuICAgIHJlbmRlckJhdGNoKCk7XG4gIH0pO1xuXG4gIHRyeSB7XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgaW52b2tlPHsgc3VjY2VlZGVkOiBudW1iZXI7IGZhaWxlZDogeyBwYXRoOiBzdHJpbmc7IGVycm9yOiBzdHJpbmcgfVtdIH0+KCdiYXRjaF9wcm9jZXNzJywge1xuICAgICAgaW5wdXRQYXRoczogc3RhdGUuYmF0Y2hGaWxlcyxcbiAgICAgIG91dHB1dERpcjogc3RhdGUuYmF0Y2hPdXRwdXREaXIsXG4gICAgICBwYzogYnVpbGRQcm9jZXNzQ29uZmlnKCksXG4gICAgICBvdmVyd3JpdGU6IGZhbHNlLFxuICAgIH0pO1xuICAgIHN0YXRlLmJhdGNoUmVzdWx0ID0gcmVzdWx0O1xuICAgIHNldFN0YXR1cyhgQmF0Y2ggZG9uZTogJHtyZXN1bHQuc3VjY2VlZGVkfSBzdWNjZWVkZWQsICR7cmVzdWx0LmZhaWxlZC5sZW5ndGh9IGZhaWxlZGAsIHJlc3VsdC5mYWlsZWQubGVuZ3RoID4gMCA/ICdlcnJvcicgOiAnc3VjY2VzcycpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgc2V0U3RhdHVzKCdCYXRjaCBlcnJvcjogJyArIGUsICdlcnJvcicpO1xuICB9IGZpbmFsbHkge1xuICAgIHN0YXRlLmJhdGNoUnVubmluZyA9IGZhbHNlO1xuICAgIHN0YXRlLmJhdGNoUHJvZ3Jlc3MgPSBudWxsO1xuICAgIGlmICh0eXBlb2YgdW5saXN0ZW4gPT09ICdmdW5jdGlvbicpIHVubGlzdGVuKCk7XG4gICAgcmVuZGVyQmF0Y2goKTtcbiAgfVxufVxuXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cbi8vIFNoZWV0IHRhYlxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmZ1bmN0aW9uIHJlbmRlclNoZWV0KCk6IHZvaWQge1xuICBjb25zdCBlbCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzaGVldC1jb250ZW50JykhO1xuICBjb25zdCBzYyA9IHN0YXRlLnNoZWV0Q29uZmlnO1xuICBjb25zdCBkaXMgPSBzdGF0ZS5zaGVldFByb2Nlc3NpbmcgPyAnIGRpc2FibGVkJyA6ICcnO1xuICBsZXQgaHRtbCA9ICcnO1xuXG4gIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZWN0aW9uXCI+JztcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXRpdGxlXCI+U3ByaXRlIFNoZWV0IFByb2Nlc3Npbmc8L2Rpdj4nO1xuICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtZGVzY1wiPlNwbGl0IGEgc3ByaXRlIHNoZWV0IGludG8gaW5kaXZpZHVhbCB0aWxlcywgcnVuIHRoZSBub3JtYWxpemUgcGlwZWxpbmUgb24gZWFjaCBvbmUsIHRoZW4gcmVhc3NlbWJsZSBpbnRvIGEgY2xlYW4gc2hlZXQuIFlvdSBjYW4gYWxzbyBleHBvcnQgZWFjaCB0aWxlIGFzIGEgc2VwYXJhdGUgZmlsZSBvciBnZW5lcmF0ZSBhbiBhbmltYXRlZCBHSUYuPC9kaXY+JztcbiAgaWYgKCFzdGF0ZS5pbWFnZUxvYWRlZCkge1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1kZXNjXCIgc3R5bGU9XCJjb2xvcjp2YXIoLS15ZWxsb3cpO21hcmdpbi10b3A6NnB4XCI+TG9hZCBhbiBpbWFnZSBmaXJzdCBpbiB0aGUgUHJldmlldyB0YWIuPC9kaXY+JztcbiAgfVxuICBodG1sICs9ICc8L2Rpdj4nO1xuXG4gIC8vIE1vZGUgdG9nZ2xlXG4gIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZWN0aW9uXCI+JztcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNldHRpbmctbGFiZWxcIiBzdHlsZT1cIm1hcmdpbi1ib3R0b206NHB4XCI+U3BsaXQgTW9kZTwvZGl2Pic7XG4gIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1tb2RlLXRvZ2dsZVwiPic7XG4gIGh0bWwgKz0gYDxidXR0b24gY2xhc3M9XCJzaGVldC1tb2RlLWJ0biR7c3RhdGUuc2hlZXRNb2RlID09PSAnZml4ZWQnID8gJyBhY3RpdmUnIDogJyd9XCIgZGF0YS1tb2RlPVwiZml4ZWRcIj5GaXhlZCBHcmlkPC9idXR0b24+YDtcbiAgaHRtbCArPSBgPGJ1dHRvbiBjbGFzcz1cInNoZWV0LW1vZGUtYnRuJHtzdGF0ZS5zaGVldE1vZGUgPT09ICdhdXRvJyA/ICcgYWN0aXZlJyA6ICcnfVwiIGRhdGEtbW9kZT1cImF1dG9cIj5BdXRvLVNwbGl0PC9idXR0b24+YDtcbiAgaHRtbCArPSAnPC9kaXY+JztcbiAgaWYgKHN0YXRlLnNoZWV0TW9kZSA9PT0gJ2ZpeGVkJykge1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1oZWxwXCI+VXNlIHdoZW4geW91ciBzaGVldCBoYXMgYSB1bmlmb3JtIGdyaWQgJm1kYXNoOyBhbGwgdGlsZXMgYXJlIHRoZSBzYW1lIHNpemUgd2l0aCBjb25zaXN0ZW50IHNwYWNpbmcuPC9kaXY+JztcbiAgfSBlbHNlIHtcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtaGVscFwiPlVzZSB3aGVuIHRpbGVzIGFyZSBkaWZmZXJlbnQgc2l6ZXMgb3IgaXJyZWd1bGFybHkgcGxhY2VkLiBEZXRlY3RzIHNwcml0ZXMgYXV0b21hdGljYWxseSBieSBmaW5kaW5nIHNlcGFyYXRvciByb3dzL2NvbHVtbnMuIDxzdHJvbmc+U3ByaXRlcyBtdXN0IGJlIG9uIGEgcHVyZSB3aGl0ZSBiYWNrZ3JvdW5kLjwvc3Ryb25nPjwvZGl2Pic7XG4gIH1cbiAgaHRtbCArPSAnPC9kaXY+JztcblxuICAvLyBNb2RlLXNwZWNpZmljIHNldHRpbmdzXG4gIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZWN0aW9uXCI+JztcbiAgaWYgKHN0YXRlLnNoZWV0TW9kZSA9PT0gJ2ZpeGVkJykge1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZXR0aW5nXCI+PHNwYW4gY2xhc3M9XCJzaGVldC1zZXR0aW5nLWxhYmVsXCI+VGlsZSBXaWR0aDwvc3Bhbj4nO1xuICAgIGh0bWwgKz0gYDxpbnB1dCBjbGFzcz1cInNoZWV0LWlucHV0XCIgdHlwZT1cIm51bWJlclwiIGlkPVwic2hlZXQtdHdcIiB2YWx1ZT1cIiR7c2MudGlsZVdpZHRoID8/ICcnfVwiIHBsYWNlaG9sZGVyPVwicHhcIiR7ZGlzfT48L2Rpdj5gO1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1oZWxwXCI+V2lkdGggb2YgZWFjaCB0aWxlIGluIHBpeGVscy4gUmVxdWlyZWQuPC9kaXY+JztcblxuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZXR0aW5nXCI+PHNwYW4gY2xhc3M9XCJzaGVldC1zZXR0aW5nLWxhYmVsXCI+VGlsZSBIZWlnaHQ8L3NwYW4+JztcbiAgICBodG1sICs9IGA8aW5wdXQgY2xhc3M9XCJzaGVldC1pbnB1dFwiIHR5cGU9XCJudW1iZXJcIiBpZD1cInNoZWV0LXRoXCIgdmFsdWU9XCIke3NjLnRpbGVIZWlnaHQgPz8gJyd9XCIgcGxhY2Vob2xkZXI9XCJweFwiJHtkaXN9PjwvZGl2PmA7XG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LWhlbHBcIj5IZWlnaHQgb2YgZWFjaCB0aWxlIGluIHBpeGVscy4gUmVxdWlyZWQuPC9kaXY+JztcblxuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZXR0aW5nXCI+PHNwYW4gY2xhc3M9XCJzaGVldC1zZXR0aW5nLWxhYmVsXCI+U3BhY2luZzwvc3Bhbj4nO1xuICAgIGh0bWwgKz0gYDxpbnB1dCBjbGFzcz1cInNoZWV0LWlucHV0XCIgdHlwZT1cIm51bWJlclwiIGlkPVwic2hlZXQtc3BcIiB2YWx1ZT1cIiR7c2Muc3BhY2luZ31cIiBwbGFjZWhvbGRlcj1cIjBcIiR7ZGlzfT48L2Rpdj5gO1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1oZWxwXCI+R2FwIGJldHdlZW4gdGlsZXMgaW4gcGl4ZWxzLiBTZXQgdG8gMCBpZiB0aWxlcyBhcmUgcGFja2VkIGVkZ2UtdG8tZWRnZS48L2Rpdj4nO1xuXG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNldHRpbmdcIj48c3BhbiBjbGFzcz1cInNoZWV0LXNldHRpbmctbGFiZWxcIj5NYXJnaW48L3NwYW4+JztcbiAgICBodG1sICs9IGA8aW5wdXQgY2xhc3M9XCJzaGVldC1pbnB1dFwiIHR5cGU9XCJudW1iZXJcIiBpZD1cInNoZWV0LW1nXCIgdmFsdWU9XCIke3NjLm1hcmdpbn1cIiBwbGFjZWhvbGRlcj1cIjBcIiR7ZGlzfT48L2Rpdj5gO1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1oZWxwXCI+Qm9yZGVyIGFyb3VuZCB0aGUgZW50aXJlIHNoZWV0IGluIHBpeGVscy4gVXN1YWxseSAwLjwvZGl2Pic7XG4gIH0gZWxzZSB7XG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNldHRpbmdcIj48c3BhbiBjbGFzcz1cInNoZWV0LXNldHRpbmctbGFiZWxcIj5TZXAuIFRocmVzaG9sZDwvc3Bhbj4nO1xuICAgIGh0bWwgKz0gYDxpbnB1dCBjbGFzcz1cInNoZWV0LWlucHV0XCIgdHlwZT1cIm51bWJlclwiIGlkPVwic2hlZXQtc2VwXCIgdmFsdWU9XCIke3NjLnNlcGFyYXRvclRocmVzaG9sZH1cIiBzdGVwPVwiMC4wNVwiIG1pbj1cIjBcIiBtYXg9XCIxXCIke2Rpc30+PC9kaXY+YDtcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtaGVscFwiPkhvdyB1bmlmb3JtIGEgcm93L2NvbHVtbiBtdXN0IGJlIHRvIGNvdW50IGFzIGEgc2VwYXJhdG9yICgwJm5kYXNoOzEpLiBIaWdoZXIgPSBzdHJpY3Rlci4gMC45MCB3b3JrcyBmb3IgbW9zdCBzaGVldHMuPC9kaXY+JztcblxuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZXR0aW5nXCI+PHNwYW4gY2xhc3M9XCJzaGVldC1zZXR0aW5nLWxhYmVsXCI+TWluIFNwcml0ZSBTaXplPC9zcGFuPic7XG4gICAgaHRtbCArPSBgPGlucHV0IGNsYXNzPVwic2hlZXQtaW5wdXRcIiB0eXBlPVwibnVtYmVyXCIgaWQ9XCJzaGVldC1taW5cIiB2YWx1ZT1cIiR7c2MubWluU3ByaXRlU2l6ZX1cIiBtaW49XCIxXCIke2Rpc30+PC9kaXY+YDtcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtaGVscFwiPklnbm9yZSBkZXRlY3RlZCByZWdpb25zIHNtYWxsZXIgdGhhbiB0aGlzIG1hbnkgcGl4ZWxzLiBGaWx0ZXJzIG91dCBub2lzZSBhbmQgdGlueSBmcmFnbWVudHMuPC9kaXY+JztcblxuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZXR0aW5nXCI+PHNwYW4gY2xhc3M9XCJzaGVldC1zZXR0aW5nLWxhYmVsXCI+UGFkZGluZzwvc3Bhbj4nO1xuICAgIGh0bWwgKz0gYDxpbnB1dCBjbGFzcz1cInNoZWV0LWlucHV0XCIgdHlwZT1cIm51bWJlclwiIGlkPVwic2hlZXQtcGFkXCIgdmFsdWU9XCIke3NjLnBhZH1cIiBtaW49XCIwXCIke2Rpc30+PC9kaXY+YDtcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtaGVscFwiPkV4dHJhIHBpeGVscyB0byBpbmNsdWRlIGFyb3VuZCBlYWNoIGRldGVjdGVkIHNwcml0ZS4gVXNlZnVsIGlmIGF1dG8tZGV0ZWN0aW9uIGNyb3BzIHRvbyB0aWdodGx5LjwvZGl2Pic7XG4gIH1cbiAgaHRtbCArPSAnPC9kaXY+JztcblxuICAvLyBTa2lwIG5vcm1hbGl6ZSB0b2dnbGVcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNlY3Rpb25cIj4nO1xuICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtc2V0dGluZ1wiPjxzcGFuIGNsYXNzPVwic2hlZXQtc2V0dGluZy1sYWJlbFwiPlNraXAgTm9ybWFsaXplPC9zcGFuPic7XG4gIGh0bWwgKz0gYDxidXR0b24gY2xhc3M9XCJiYXRjaC1idG4gYmF0Y2gtYnRuLWRpbVwiIGlkPVwic2hlZXQtbm8tbm9ybWFsaXplXCIgc3R5bGU9XCJtaW4td2lkdGg6NDBweFwiJHtkaXN9PiR7c2Mubm9Ob3JtYWxpemUgPyAnb24nIDogJ29mZid9PC9idXR0b24+PC9kaXY+YDtcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LWhlbHBcIj5XaGVuIG9uLCB0aWxlcyBhcmUgc3BsaXQgYW5kIHJlYXNzZW1ibGVkIHdpdGhvdXQgcnVubmluZyB0aGUgcGlwZWxpbmUuIFVzZWZ1bCBmb3IganVzdCBleHRyYWN0aW5nIG9yIHJlYXJyYW5naW5nIHRpbGVzLjwvZGl2Pic7XG4gIGh0bWwgKz0gJzwvZGl2Pic7XG5cbiAgLy8gQWN0aW9uIGJ1dHRvbnNcbiAgY29uc3QgY2FuQWN0ID0gc3RhdGUuaW1hZ2VMb2FkZWQgJiYgIXN0YXRlLnNoZWV0UHJvY2Vzc2luZztcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNlY3Rpb25cIj4nO1xuICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtYWN0aW9uc1wiPic7XG4gIGh0bWwgKz0gYDxidXR0b24gY2xhc3M9XCJiYXRjaC1idG5cIiBpZD1cInNoZWV0LXByZXZpZXctYnRuXCIke2NhbkFjdCA/ICcnIDogJyBkaXNhYmxlZCd9PlByZXZpZXcgU3BsaXQ8L2J1dHRvbj5gO1xuICBodG1sICs9IGA8YnV0dG9uIGNsYXNzPVwiYmF0Y2gtYnRuIGJhdGNoLWJ0bi1wcmltYXJ5XCIgaWQ9XCJzaGVldC1wcm9jZXNzLWJ0blwiJHtjYW5BY3QgPyAnJyA6ICcgZGlzYWJsZWQnfT5Qcm9jZXNzIFNoZWV0PC9idXR0b24+YDtcbiAgaHRtbCArPSBgPGJ1dHRvbiBjbGFzcz1cImJhdGNoLWJ0blwiIGlkPVwic2hlZXQtc2F2ZS10aWxlcy1idG5cIiR7c3RhdGUuc2hlZXRQcmV2aWV3ICYmICFzdGF0ZS5zaGVldFByb2Nlc3NpbmcgPyAnJyA6ICcgZGlzYWJsZWQnfT5TYXZlIFRpbGVzPC9idXR0b24+YDtcbiAgaHRtbCArPSAnPC9kaXY+JztcbiAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LWhlbHBcIj48c3Ryb25nPlByZXZpZXcgU3BsaXQ8L3N0cm9uZz4gc2hvd3MgaG93IG1hbnkgdGlsZXMgd2lsbCBiZSBleHRyYWN0ZWQuIDxzdHJvbmc+UHJvY2VzcyBTaGVldDwvc3Ryb25nPiBydW5zIHRoZSBub3JtYWxpemUgcGlwZWxpbmUgb24gZWFjaCB0aWxlIGFuZCByZWFzc2VtYmxlcy4gPHN0cm9uZz5TYXZlIFRpbGVzPC9zdHJvbmc+IGV4cG9ydHMgZWFjaCB0aWxlIGFzIGEgc2VwYXJhdGUgUE5HLjwvZGl2Pic7XG4gIGh0bWwgKz0gJzwvZGl2Pic7XG5cbiAgLy8gUHJldmlldyBpbmZvXG4gIGlmIChzdGF0ZS5zaGVldFByZXZpZXcpIHtcbiAgICBjb25zdCBwID0gc3RhdGUuc2hlZXRQcmV2aWV3O1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZWN0aW9uXCI+JztcbiAgICBodG1sICs9IGA8ZGl2IGNsYXNzPVwic2hlZXQtaW5mb1wiPiR7cC50aWxlQ291bnR9IHRpbGVzICZtZGFzaDsgJHtwLmNvbHN9XFx1MDBkNyR7cC5yb3dzfSBncmlkICZtZGFzaDsgJHtwLnRpbGVXaWR0aH1cXHUwMGQ3JHtwLnRpbGVIZWlnaHR9cHggZWFjaDwvZGl2PmA7XG4gICAgaHRtbCArPSAnPC9kaXY+JztcblxuICAgIC8vIEdJRiBhbmltYXRpb24gc2VjdGlvblxuICAgIGNvbnN0IGdpZkRpcyA9IHN0YXRlLmdpZkdlbmVyYXRpbmcgPyAnIGRpc2FibGVkJyA6ICcnO1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZWN0aW9uXCI+JztcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtdGl0bGVcIiBzdHlsZT1cIm1hcmdpbi10b3A6NHB4XCI+R0lGIEFuaW1hdGlvbjwvZGl2Pic7XG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LWhlbHBcIj5HZW5lcmF0ZSBhbiBhbmltYXRlZCBHSUYgZnJvbSB0aGUgcHJvY2Vzc2VkIHRpbGVzLiBQcmV2aWV3IGl0IGhlcmUgb3IgZXhwb3J0IHRvIGEgZmlsZS48L2Rpdj4nO1xuXG4gICAgLy8gTW9kZSB0b2dnbGVcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtc2V0dGluZ1wiPjxzcGFuIGNsYXNzPVwic2hlZXQtc2V0dGluZy1sYWJlbFwiPkFuaW1hdGU8L3NwYW4+JztcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtbW9kZS10b2dnbGVcIj4nO1xuICAgIGh0bWwgKz0gYDxidXR0b24gY2xhc3M9XCJzaGVldC1tb2RlLWJ0biBnaWYtbW9kZS1idG4ke3N0YXRlLmdpZk1vZGUgPT09ICdyb3cnID8gJyBhY3RpdmUnIDogJyd9XCIgZGF0YS1naWYtbW9kZT1cInJvd1wiJHtnaWZEaXN9PkJ5IFJvdzwvYnV0dG9uPmA7XG4gICAgaHRtbCArPSBgPGJ1dHRvbiBjbGFzcz1cInNoZWV0LW1vZGUtYnRuIGdpZi1tb2RlLWJ0biR7c3RhdGUuZ2lmTW9kZSA9PT0gJ2FsbCcgPyAnIGFjdGl2ZScgOiAnJ31cIiBkYXRhLWdpZi1tb2RlPVwiYWxsXCIke2dpZkRpc30+RW50aXJlIFNoZWV0PC9idXR0b24+YDtcbiAgICBodG1sICs9ICc8L2Rpdj48L2Rpdj4nO1xuXG4gICAgLy8gUm93IHNlbGVjdG9yIChyb3cgbW9kZSBvbmx5KVxuICAgIGlmIChzdGF0ZS5naWZNb2RlID09PSAncm93Jykge1xuICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNldHRpbmdcIj48c3BhbiBjbGFzcz1cInNoZWV0LXNldHRpbmctbGFiZWxcIj5Sb3c8L3NwYW4+JztcbiAgICAgIGh0bWwgKz0gYDxpbnB1dCBjbGFzcz1cInNoZWV0LWlucHV0XCIgdHlwZT1cIm51bWJlclwiIGlkPVwiZ2lmLXJvd1wiIHZhbHVlPVwiJHtzdGF0ZS5naWZSb3d9XCIgbWluPVwiMFwiIG1heD1cIiR7cC5yb3dzIC0gMX1cIiR7Z2lmRGlzfT48L2Rpdj5gO1xuICAgICAgaHRtbCArPSBgPGRpdiBjbGFzcz1cInNoZWV0LWhlbHBcIj5XaGljaCByb3cgdG8gYW5pbWF0ZSAoMFxcdTIwMTMke3Aucm93cyAtIDF9KS4gRWFjaCByb3cgYmVjb21lcyBvbmUgYW5pbWF0aW9uIHNlcXVlbmNlLjwvZGl2PmA7XG4gICAgfVxuXG4gICAgLy8gRlBTIGlucHV0XG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LXNldHRpbmdcIj48c3BhbiBjbGFzcz1cInNoZWV0LXNldHRpbmctbGFiZWxcIj5GcmFtZSBSYXRlPC9zcGFuPic7XG4gICAgaHRtbCArPSBgPGlucHV0IGNsYXNzPVwic2hlZXQtaW5wdXRcIiB0eXBlPVwibnVtYmVyXCIgaWQ9XCJnaWYtZnBzXCIgdmFsdWU9XCIke3N0YXRlLmdpZkZwc31cIiBtaW49XCIxXCIgbWF4PVwiMTAwXCIke2dpZkRpc30+PC9kaXY+YDtcbiAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtaGVscFwiPkZyYW1lcyBwZXIgc2Vjb25kICgxXFx1MjAxMzEwMCkuIDEwIGZwcyBpcyBhIGdvb2QgZGVmYXVsdCBmb3IgcGl4ZWwgYXJ0IGFuaW1hdGlvbnMuPC9kaXY+JztcblxuICAgIC8vIEFjdGlvbiBidXR0b25zXG4gICAgaHRtbCArPSAnPGRpdiBjbGFzcz1cInNoZWV0LWFjdGlvbnNcIiBzdHlsZT1cIm1hcmdpbi10b3A6NHB4XCI+JztcbiAgICBodG1sICs9IGA8YnV0dG9uIGNsYXNzPVwiYmF0Y2gtYnRuIGJhdGNoLWJ0bi1wcmltYXJ5XCIgaWQ9XCJnaWYtcHJldmlldy1idG5cIiR7Z2lmRGlzfT5QcmV2aWV3IEdJRjwvYnV0dG9uPmA7XG4gICAgaHRtbCArPSBgPGJ1dHRvbiBjbGFzcz1cImJhdGNoLWJ0blwiIGlkPVwiZ2lmLWV4cG9ydC1idG5cIiR7c3RhdGUuZ2lmUHJldmlld1VybCAmJiAhc3RhdGUuZ2lmR2VuZXJhdGluZyA/ICcnIDogJyBkaXNhYmxlZCd9PkV4cG9ydCBHSUY8L2J1dHRvbj5gO1xuICAgIGh0bWwgKz0gJzwvZGl2Pic7XG5cbiAgICAvLyBHZW5lcmF0aW5nIGluZGljYXRvclxuICAgIGlmIChzdGF0ZS5naWZHZW5lcmF0aW5nKSB7XG4gICAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwic2hlZXQtaW5mb1wiIHN0eWxlPVwiY29sb3I6dmFyKC0tbWF1dmUpO21hcmdpbi10b3A6NnB4XCI+R2VuZXJhdGluZyBHSUYuLi48L2Rpdj4nO1xuICAgIH1cblxuICAgIC8vIFByZXZpZXcgYXJlYVxuICAgIGlmIChzdGF0ZS5naWZQcmV2aWV3VXJsKSB7XG4gICAgICBodG1sICs9ICc8ZGl2IGNsYXNzPVwiZ2lmLXByZXZpZXctY29udGFpbmVyXCI+JztcbiAgICAgIGh0bWwgKz0gYDxpbWcgY2xhc3M9XCJnaWYtcHJldmlldy1pbWdcIiBzcmM9XCIke3N0YXRlLmdpZlByZXZpZXdVcmx9XCIgYWx0PVwiR0lGIFByZXZpZXdcIj5gO1xuICAgICAgaHRtbCArPSAnPC9kaXY+JztcbiAgICB9XG5cbiAgICBodG1sICs9ICc8L2Rpdj4nO1xuICB9XG5cbiAgaWYgKHN0YXRlLnNoZWV0UHJvY2Vzc2luZykge1xuICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9XCJzaGVldC1zZWN0aW9uXCI+PGRpdiBjbGFzcz1cInNoZWV0LWluZm9cIiBzdHlsZT1cImNvbG9yOnZhcigtLW1hdXZlKVwiPlByb2Nlc3NpbmcuLi48L2Rpdj48L2Rpdj4nO1xuICB9XG5cbiAgZWwuaW5uZXJIVE1MID0gaHRtbDtcbn1cblxuZnVuY3Rpb24gcmVhZFNoZWV0Q29uZmlnKCk6IHZvaWQge1xuICBjb25zdCBzYyA9IHN0YXRlLnNoZWV0Q29uZmlnO1xuICBpZiAoc3RhdGUuc2hlZXRNb2RlID09PSAnZml4ZWQnKSB7XG4gICAgY29uc3QgdHcgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc2hlZXQtdHcnKSBhcyBIVE1MSW5wdXRFbGVtZW50IHwgbnVsbDtcbiAgICBjb25zdCB0aCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzaGVldC10aCcpIGFzIEhUTUxJbnB1dEVsZW1lbnQgfCBudWxsO1xuICAgIGNvbnN0IHNwID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3NoZWV0LXNwJykgYXMgSFRNTElucHV0RWxlbWVudCB8IG51bGw7XG4gICAgY29uc3QgbWcgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc2hlZXQtbWcnKSBhcyBIVE1MSW5wdXRFbGVtZW50IHwgbnVsbDtcbiAgICBpZiAodHcpIHsgY29uc3QgdiA9IHBhcnNlSW50KHR3LnZhbHVlKTsgc2MudGlsZVdpZHRoID0gaXNOYU4odikgfHwgdiA8IDEgPyBudWxsIDogdjsgfVxuICAgIGlmICh0aCkgeyBjb25zdCB2ID0gcGFyc2VJbnQodGgudmFsdWUpOyBzYy50aWxlSGVpZ2h0ID0gaXNOYU4odikgfHwgdiA8IDEgPyBudWxsIDogdjsgfVxuICAgIGlmIChzcCkgeyBjb25zdCB2ID0gcGFyc2VJbnQoc3AudmFsdWUpOyBzYy5zcGFjaW5nID0gaXNOYU4odikgPyAwIDogTWF0aC5tYXgoMCwgdik7IH1cbiAgICBpZiAobWcpIHsgY29uc3QgdiA9IHBhcnNlSW50KG1nLnZhbHVlKTsgc2MubWFyZ2luID0gaXNOYU4odikgPyAwIDogTWF0aC5tYXgoMCwgdik7IH1cbiAgfSBlbHNlIHtcbiAgICBjb25zdCBzZXAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc2hlZXQtc2VwJykgYXMgSFRNTElucHV0RWxlbWVudCB8IG51bGw7XG4gICAgY29uc3QgbWluID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3NoZWV0LW1pbicpIGFzIEhUTUxJbnB1dEVsZW1lbnQgfCBudWxsO1xuICAgIGNvbnN0IHBhZCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzaGVldC1wYWQnKSBhcyBIVE1MSW5wdXRFbGVtZW50IHwgbnVsbDtcbiAgICBpZiAoc2VwKSB7IGNvbnN0IHYgPSBwYXJzZUZsb2F0KHNlcC52YWx1ZSk7IHNjLnNlcGFyYXRvclRocmVzaG9sZCA9IGlzTmFOKHYpID8gMC45MCA6IE1hdGgubWF4KDAsIE1hdGgubWluKDEsIHYpKTsgfVxuICAgIGlmIChtaW4pIHsgY29uc3QgdiA9IHBhcnNlSW50KG1pbi52YWx1ZSk7IHNjLm1pblNwcml0ZVNpemUgPSBpc05hTih2KSA/IDggOiBNYXRoLm1heCgxLCB2KTsgfVxuICAgIGlmIChwYWQpIHsgY29uc3QgdiA9IHBhcnNlSW50KHBhZC52YWx1ZSk7IHNjLnBhZCA9IGlzTmFOKHYpID8gMCA6IE1hdGgubWF4KDAsIHYpOyB9XG4gIH1cbn1cblxuZnVuY3Rpb24gYnVpbGRTaGVldEFyZ3MoKTogUmVjb3JkPHN0cmluZywgdW5rbm93bj4ge1xuICBjb25zdCBzYyA9IHN0YXRlLnNoZWV0Q29uZmlnO1xuICByZXR1cm4ge1xuICAgIG1vZGU6IHN0YXRlLnNoZWV0TW9kZSxcbiAgICB0aWxlV2lkdGg6IHNjLnRpbGVXaWR0aCxcbiAgICB0aWxlSGVpZ2h0OiBzYy50aWxlSGVpZ2h0LFxuICAgIHNwYWNpbmc6IHNjLnNwYWNpbmcsXG4gICAgbWFyZ2luOiBzYy5tYXJnaW4sXG4gICAgc2VwYXJhdG9yVGhyZXNob2xkOiBzYy5zZXBhcmF0b3JUaHJlc2hvbGQsXG4gICAgbWluU3ByaXRlU2l6ZTogc2MubWluU3ByaXRlU2l6ZSxcbiAgICBwYWQ6IHNjLnBhZCxcbiAgICBub05vcm1hbGl6ZTogc2Mubm9Ob3JtYWxpemUgfHwgbnVsbCxcbiAgfTtcbn1cblxuYXN5bmMgZnVuY3Rpb24gc2hlZXRQcmV2aWV3QWN0aW9uKCk6IFByb21pc2U8dm9pZD4ge1xuICBpZiAoIXN0YXRlLmltYWdlTG9hZGVkIHx8IHN0YXRlLnNoZWV0UHJvY2Vzc2luZykgcmV0dXJuO1xuICByZWFkU2hlZXRDb25maWcoKTtcbiAgc3RhdGUuc2hlZXRQcm9jZXNzaW5nID0gdHJ1ZTtcbiAgcmVuZGVyU2hlZXQoKTtcbiAgdHJ5IHtcbiAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBpbnZva2U8eyB0aWxlQ291bnQ6IG51bWJlcjsgdGlsZVdpZHRoOiBudW1iZXI7IHRpbGVIZWlnaHQ6IG51bWJlcjsgY29sczogbnVtYmVyOyByb3dzOiBudW1iZXIgfT4oJ3NoZWV0X3ByZXZpZXcnLCBidWlsZFNoZWV0QXJncygpKTtcbiAgICBzdGF0ZS5zaGVldFByZXZpZXcgPSByZXN1bHQ7XG4gICAgc2V0U3RhdHVzKGBTaGVldDogJHtyZXN1bHQudGlsZUNvdW50fSB0aWxlcyAoJHtyZXN1bHQuY29sc31cXHUwMGQ3JHtyZXN1bHQucm93c30pYCwgJ3N1Y2Nlc3MnKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIHNldFN0YXR1cygnU2hlZXQgZXJyb3I6ICcgKyBlLCAnZXJyb3InKTtcbiAgICBzdGF0ZS5zaGVldFByZXZpZXcgPSBudWxsO1xuICB9IGZpbmFsbHkge1xuICAgIHN0YXRlLnNoZWV0UHJvY2Vzc2luZyA9IGZhbHNlO1xuICAgIHJlbmRlclNoZWV0KCk7XG4gIH1cbn1cblxuYXN5bmMgZnVuY3Rpb24gc2hlZXRQcm9jZXNzQWN0aW9uKCk6IFByb21pc2U8dm9pZD4ge1xuICBpZiAoIXN0YXRlLmltYWdlTG9hZGVkIHx8IHN0YXRlLnNoZWV0UHJvY2Vzc2luZykgcmV0dXJuO1xuICByZWFkU2hlZXRDb25maWcoKTtcbiAgc3RhdGUuc2hlZXRQcm9jZXNzaW5nID0gdHJ1ZTtcbiAgc3RhdGUuZ2lmUHJldmlld1VybCA9IG51bGw7XG4gIHJlbmRlclNoZWV0KCk7XG4gIHNldFN0YXR1cygnUHJvY2Vzc2luZyBzaGVldC4uLicsICdwcm9jZXNzaW5nJyk7XG4gIGNvbnN0IHQwID0gcGVyZm9ybWFuY2Uubm93KCk7XG4gIHRyeSB7XG4gICAgY29uc3QgYXJncyA9IHsgLi4uYnVpbGRTaGVldEFyZ3MoKSwgcGM6IGJ1aWxkUHJvY2Vzc0NvbmZpZygpIH07XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgaW52b2tlPHsgdGlsZUNvdW50OiBudW1iZXI7IHRpbGVXaWR0aDogbnVtYmVyOyB0aWxlSGVpZ2h0OiBudW1iZXI7IGNvbHM6IG51bWJlcjsgcm93czogbnVtYmVyOyBvdXRwdXRXaWR0aDogbnVtYmVyOyBvdXRwdXRIZWlnaHQ6IG51bWJlciB9Pignc2hlZXRfcHJvY2VzcycsIGFyZ3MpO1xuICAgIHN0YXRlLnNoZWV0UHJldmlldyA9IHJlc3VsdDtcblxuICAgIC8vIFVwZGF0ZSBwcmV2aWV3IHdpdGggdGhlIHByb2Nlc3NlZCBzaGVldFxuICAgIGNvbnN0IHByb2NVcmwgPSBhd2FpdCBsb2FkSW1hZ2VCbG9iKCdwcm9jZXNzZWQnKTtcbiAgICAoZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Byb2Nlc3NlZC1pbWcnKSBhcyBIVE1MSW1hZ2VFbGVtZW50KS5zcmMgPSBwcm9jVXJsO1xuICAgIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdwcm9jZXNzZWQtZGltcycpIS50ZXh0Q29udGVudCA9IGAke3Jlc3VsdC5vdXRwdXRXaWR0aH1cXHUwMGQ3JHtyZXN1bHQub3V0cHV0SGVpZ2h0fWA7XG4gICAgKGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZXR0aW5ncy1wcmV2aWV3LWltZycpIGFzIEhUTUxJbWFnZUVsZW1lbnQpLnNyYyA9IHByb2NVcmw7XG5cbiAgICBjb25zdCBlbGFwc2VkID0gKChwZXJmb3JtYW5jZS5ub3coKSAtIHQwKSAvIDEwMDApLnRvRml4ZWQoMik7XG4gICAgc2V0U3RhdHVzKGBTaGVldCBwcm9jZXNzZWQ6ICR7cmVzdWx0LnRpbGVDb3VudH0gdGlsZXMsICR7cmVzdWx0Lm91dHB1dFdpZHRofVxcdTAwZDcke3Jlc3VsdC5vdXRwdXRIZWlnaHR9ICgke2VsYXBzZWR9cylgLCAnc3VjY2VzcycpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgc2V0U3RhdHVzKCdTaGVldCBlcnJvcjogJyArIGUsICdlcnJvcicpO1xuICB9IGZpbmFsbHkge1xuICAgIHN0YXRlLnNoZWV0UHJvY2Vzc2luZyA9IGZhbHNlO1xuICAgIHJlbmRlclNoZWV0KCk7XG4gIH1cbn1cblxuYXN5bmMgZnVuY3Rpb24gc2hlZXRTYXZlVGlsZXNBY3Rpb24oKTogUHJvbWlzZTx2b2lkPiB7XG4gIHRyeSB7XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgb3BlbkRpYWxvZyh7IGRpcmVjdG9yeTogdHJ1ZSB9KTtcbiAgICBpZiAocmVzdWx0KSB7XG4gICAgICBjb25zdCBkaXIgPSBBcnJheS5pc0FycmF5KHJlc3VsdCkgPyByZXN1bHRbMF0gOiByZXN1bHQ7XG4gICAgICBjb25zdCBjb3VudCA9IGF3YWl0IGludm9rZTxudW1iZXI+KCdzaGVldF9zYXZlX3RpbGVzJywgeyBvdXRwdXREaXI6IGRpciB9KTtcbiAgICAgIHNldFN0YXR1cyhgU2F2ZWQgJHtjb3VudH0gdGlsZXMgdG8gJHtkaXIuc3BsaXQoJy8nKS5wb3AoKSEuc3BsaXQoJ1xcXFwnKS5wb3AoKSF9YCwgJ3N1Y2Nlc3MnKTtcbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBzZXRTdGF0dXMoJ0Vycm9yIHNhdmluZyB0aWxlczogJyArIGUsICdlcnJvcicpO1xuICB9XG59XG5cbmZ1bmN0aW9uIHJlYWRHaWZDb25maWcoKTogdm9pZCB7XG4gIGNvbnN0IHJvd0VsID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2dpZi1yb3cnKSBhcyBIVE1MSW5wdXRFbGVtZW50IHwgbnVsbDtcbiAgY29uc3QgZnBzRWwgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZ2lmLWZwcycpIGFzIEhUTUxJbnB1dEVsZW1lbnQgfCBudWxsO1xuICBpZiAocm93RWwpIHtcbiAgICBjb25zdCB2ID0gcGFyc2VJbnQocm93RWwudmFsdWUpO1xuICAgIHN0YXRlLmdpZlJvdyA9IGlzTmFOKHYpID8gMCA6IE1hdGgubWF4KDAsIHYpO1xuICB9XG4gIGlmIChmcHNFbCkge1xuICAgIGNvbnN0IHYgPSBwYXJzZUludChmcHNFbC52YWx1ZSk7XG4gICAgc3RhdGUuZ2lmRnBzID0gaXNOYU4odikgPyAxMCA6IE1hdGgubWF4KDEsIE1hdGgubWluKDEwMCwgdikpO1xuICB9XG59XG5cbmFzeW5jIGZ1bmN0aW9uIGdpZlByZXZpZXdBY3Rpb24oKTogUHJvbWlzZTx2b2lkPiB7XG4gIGlmIChzdGF0ZS5naWZHZW5lcmF0aW5nKSByZXR1cm47XG4gIHJlYWRHaWZDb25maWcoKTtcbiAgc3RhdGUuZ2lmR2VuZXJhdGluZyA9IHRydWU7XG4gIHN0YXRlLmdpZlByZXZpZXdVcmwgPSBudWxsO1xuICByZW5kZXJTaGVldCgpO1xuICBzZXRTdGF0dXMoJ0dlbmVyYXRpbmcgR0lGIHByZXZpZXcuLi4nLCAncHJvY2Vzc2luZycpO1xuICB0cnkge1xuICAgIGNvbnN0IGRhdGFVcmwgPSBhd2FpdCBpbnZva2U8c3RyaW5nPignc2hlZXRfZ2VuZXJhdGVfZ2lmJywge1xuICAgICAgbW9kZTogc3RhdGUuZ2lmTW9kZSxcbiAgICAgIHJvdzogc3RhdGUuZ2lmTW9kZSA9PT0gJ3JvdycgPyBzdGF0ZS5naWZSb3cgOiBudWxsLFxuICAgICAgZnBzOiBzdGF0ZS5naWZGcHMsXG4gICAgfSk7XG4gICAgc3RhdGUuZ2lmUHJldmlld1VybCA9IGRhdGFVcmw7XG4gICAgc2V0U3RhdHVzKCdHSUYgcHJldmlldyBnZW5lcmF0ZWQnLCAnc3VjY2VzcycpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgc2V0U3RhdHVzKCdHSUYgZXJyb3I6ICcgKyBlLCAnZXJyb3InKTtcbiAgfSBmaW5hbGx5IHtcbiAgICBzdGF0ZS5naWZHZW5lcmF0aW5nID0gZmFsc2U7XG4gICAgcmVuZGVyU2hlZXQoKTtcbiAgfVxufVxuXG5hc3luYyBmdW5jdGlvbiBnaWZFeHBvcnRBY3Rpb24oKTogUHJvbWlzZTx2b2lkPiB7XG4gIGlmICghc3RhdGUuZ2lmUHJldmlld1VybCkgcmV0dXJuO1xuICByZWFkR2lmQ29uZmlnKCk7XG4gIHRyeSB7XG4gICAgY29uc3QgZGVmYXVsdE5hbWUgPSBzdGF0ZS5naWZNb2RlID09PSAncm93JyA/IGByb3dfJHtzdGF0ZS5naWZSb3d9LmdpZmAgOiAnYW5pbWF0aW9uLmdpZic7XG4gICAgY29uc3QgcGF0aCA9IGF3YWl0IHNhdmVEaWFsb2coe1xuICAgICAgZmlsdGVyczogW3sgbmFtZTogJ0dJRicsIGV4dGVuc2lvbnM6IFsnZ2lmJ10gfV0sXG4gICAgICBkZWZhdWx0UGF0aDogZGVmYXVsdE5hbWUsXG4gICAgfSk7XG4gICAgaWYgKHBhdGgpIHtcbiAgICAgIHNldFN0YXR1cygnRXhwb3J0aW5nIEdJRi4uLicsICdwcm9jZXNzaW5nJyk7XG4gICAgICBhd2FpdCBpbnZva2UoJ3NoZWV0X2V4cG9ydF9naWYnLCB7XG4gICAgICAgIHBhdGgsXG4gICAgICAgIG1vZGU6IHN0YXRlLmdpZk1vZGUsXG4gICAgICAgIHJvdzogc3RhdGUuZ2lmTW9kZSA9PT0gJ3JvdycgPyBzdGF0ZS5naWZSb3cgOiBudWxsLFxuICAgICAgICBmcHM6IHN0YXRlLmdpZkZwcyxcbiAgICAgIH0pO1xuICAgICAgY29uc3QgZm5hbWUgPSAocGF0aCBhcyBzdHJpbmcpLnNwbGl0KCcvJykucG9wKCkhLnNwbGl0KCdcXFxcJykucG9wKCkhO1xuICAgICAgc2V0U3RhdHVzKGBHSUYgc2F2ZWQgdG8gJHtmbmFtZX1gLCAnc3VjY2VzcycpO1xuICAgIH1cbiAgfSBjYXRjaCAoZSkge1xuICAgIHNldFN0YXR1cygnR0lGIGV4cG9ydCBlcnJvcjogJyArIGUsICdlcnJvcicpO1xuICB9XG59XG5cbi8vIC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLVxuLy8gVGFiIGNsaWNrIGhhbmRsaW5nXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuZG9jdW1lbnQucXVlcnlTZWxlY3RvcignLnRhYi1iYXInKSEuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoZTogRXZlbnQpID0+IHtcbiAgY29uc3QgdGFiID0gKGUudGFyZ2V0IGFzIEhUTUxFbGVtZW50KS5jbG9zZXN0KCcudGFiJykgYXMgSFRNTEVsZW1lbnQgfCBudWxsO1xuICBpZiAodGFiKSBzd2l0Y2hUYWIodGFiLmRhdGFzZXQudGFiISk7XG59KTtcblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBEcmFnIGFuZCBkcm9wXG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuY29uc3QgZHJvcE92ZXJsYXkgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZHJvcC1vdmVybGF5JykhO1xubGV0IGRyYWdDb3VudGVyID0gMDtcblxuZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcignZHJhZ2VudGVyJywgKGU6IERyYWdFdmVudCkgPT4ge1xuICBlLnByZXZlbnREZWZhdWx0KCk7XG4gIGRyYWdDb3VudGVyKys7XG4gIGRyb3BPdmVybGF5LmNsYXNzTGlzdC5hZGQoJ2FjdGl2ZScpO1xufSk7XG5cbmRvY3VtZW50LmFkZEV2ZW50TGlzdGVuZXIoJ2RyYWdsZWF2ZScsIChlOiBEcmFnRXZlbnQpID0+IHtcbiAgZS5wcmV2ZW50RGVmYXVsdCgpO1xuICBkcmFnQ291bnRlci0tO1xuICBpZiAoZHJhZ0NvdW50ZXIgPD0gMCkge1xuICAgIGRyYWdDb3VudGVyID0gMDtcbiAgICBkcm9wT3ZlcmxheS5jbGFzc0xpc3QucmVtb3ZlKCdhY3RpdmUnKTtcbiAgfVxufSk7XG5cbmRvY3VtZW50LmFkZEV2ZW50TGlzdGVuZXIoJ2RyYWdvdmVyJywgKGU6IERyYWdFdmVudCkgPT4ge1xuICBlLnByZXZlbnREZWZhdWx0KCk7XG59KTtcblxuZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcignZHJvcCcsIGFzeW5jIChlOiBEcmFnRXZlbnQpID0+IHtcbiAgZS5wcmV2ZW50RGVmYXVsdCgpO1xuICBkcmFnQ291bnRlciA9IDA7XG4gIGRyb3BPdmVybGF5LmNsYXNzTGlzdC5yZW1vdmUoJ2FjdGl2ZScpO1xuXG4gIGNvbnN0IGZpbGVzID0gZS5kYXRhVHJhbnNmZXI/LmZpbGVzO1xuICBpZiAoZmlsZXMgJiYgZmlsZXMubGVuZ3RoID4gMCkge1xuICAgIGNvbnN0IGZpbGUgPSBmaWxlc1swXSBhcyBGaWxlICYgeyBwYXRoPzogc3RyaW5nIH07XG4gICAgaWYgKGZpbGUucGF0aCkge1xuICAgICAgYXdhaXQgb3BlbkltYWdlKGZpbGUucGF0aCk7XG4gICAgfVxuICB9XG59KTtcblxuLy8gVGF1cmkgbmF0aXZlIGZpbGUgZHJvcCBldmVudHNcbmlmICh3aW5kb3cuX19UQVVSSV9fPy5ldmVudCkge1xuICB3aW5kb3cuX19UQVVSSV9fLmV2ZW50Lmxpc3RlbigndGF1cmk6Ly9kcmFnLWRyb3AnLCBhc3luYyAoZXZlbnQ6IFRhdXJpRXZlbnQpID0+IHtcbiAgICBkcm9wT3ZlcmxheS5jbGFzc0xpc3QucmVtb3ZlKCdhY3RpdmUnKTtcbiAgICBkcmFnQ291bnRlciA9IDA7XG4gICAgY29uc3QgcGF0aHMgPSBldmVudC5wYXlsb2FkPy5wYXRocztcbiAgICBpZiAocGF0aHMgJiYgcGF0aHMubGVuZ3RoID4gMCkge1xuICAgICAgYXdhaXQgb3BlbkltYWdlKHBhdGhzWzBdKTtcbiAgICB9XG4gIH0pO1xuXG4gIHdpbmRvdy5fX1RBVVJJX18uZXZlbnQubGlzdGVuKCd0YXVyaTovL2RyYWctZW50ZXInLCAoKSA9PiB7XG4gICAgZHJvcE92ZXJsYXkuY2xhc3NMaXN0LmFkZCgnYWN0aXZlJyk7XG4gIH0pO1xuXG4gIHdpbmRvdy5fX1RBVVJJX18uZXZlbnQubGlzdGVuKCd0YXVyaTovL2RyYWctbGVhdmUnLCAoKSA9PiB7XG4gICAgZHJvcE92ZXJsYXkuY2xhc3NMaXN0LnJlbW92ZSgnYWN0aXZlJyk7XG4gICAgZHJhZ0NvdW50ZXIgPSAwO1xuICB9KTtcbn1cblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBTZXR0aW5ncyBjbGljayBoYW5kbGluZ1xuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG5cbmRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZXR0aW5ncy1saXN0JykhLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKGU6IEV2ZW50KSA9PiB7XG4gIGNvbnN0IHRhcmdldCA9IGUudGFyZ2V0IGFzIEhUTUxFbGVtZW50O1xuXG4gIC8vIENsZWFyIGJ1dHRvbiBjbGljayAow5cgdG8gcmVzZXQgbnVsbGFibGUgc2V0dGluZylcbiAgaWYgKHRhcmdldC5jbGFzc0xpc3Q/LmNvbnRhaW5zKCdzZXR0aW5nLWNsZWFyJykgJiYgIXN0YXRlLnByb2Nlc3NpbmcpIHtcbiAgICBza2lwTmV4dEJsdXJDb21taXQgPSB0cnVlO1xuICAgIGNvbnN0IGtleSA9IHRhcmdldC5kYXRhc2V0LmtleSE7XG4gICAgY2xlYXJTZXR0aW5nKGtleSk7XG4gICAgcmV0dXJuO1xuICB9XG5cbiAgLy8gQm9vbGVhbiBvciBudWxsYWJsZS1vZmYgdG9nZ2xlIGNsaWNrXG4gIGlmICh0YXJnZXQuY2xhc3NMaXN0Py5jb250YWlucygnc2V0dGluZy10b2dnbGUnKSAmJiAhc3RhdGUucHJvY2Vzc2luZykge1xuICAgIGNvbnN0IGtleSA9IHRhcmdldC5kYXRhc2V0LmtleSE7XG4gICAgY29uc3Qgcm93ID0gdGFyZ2V0LmNsb3Nlc3QoJy5zZXR0aW5nLXJvdycpIGFzIEhUTUxFbGVtZW50IHwgbnVsbDtcbiAgICBpZiAocm93KSBzdGF0ZS5zZXR0aW5nc0ZvY3VzSW5kZXggPSBwYXJzZUludChyb3cuZGF0YXNldC5pbmRleCEpO1xuICAgIGlmIChCT09MRUFOX1NFVFRJTkdTLmluY2x1ZGVzKGtleSkpIHtcbiAgICAgIGFkanVzdFNldHRpbmcoa2V5LCAxKTtcbiAgICAgIHJlbmRlclNldHRpbmdzKCk7XG4gICAgICBhdXRvUHJvY2VzcygpO1xuICAgIH0gZWxzZSB7XG4gICAgICAvLyBOdWxsYWJsZSBzZXR0aW5nIGluIFwib2ZmXCIgc3RhdGUg4oCUIGVuYWJsZSBpdFxuICAgICAgc3RhcnRFZGl0aW5nKGtleSk7XG4gICAgfVxuICAgIHJldHVybjtcbiAgfVxuXG4gIC8vIENsaWNrIG9uIHJvdyB0byBmb2N1cyBpdFxuICBjb25zdCByb3cgPSB0YXJnZXQuY2xvc2VzdCgnLnNldHRpbmctcm93JykgYXMgSFRNTEVsZW1lbnQgfCBudWxsO1xuICBpZiAocm93KSB7XG4gICAgc3RhdGUuc2V0dGluZ3NGb2N1c0luZGV4ID0gcGFyc2VJbnQocm93LmRhdGFzZXQuaW5kZXghKTtcbiAgICByZW5kZXJTZXR0aW5ncygpO1xuICB9XG59KTtcblxuLy8gQ29tbWl0IGlubGluZSBpbnB1dCBvbiBibHVyXG5sZXQgc2tpcE5leHRCbHVyQ29tbWl0ID0gZmFsc2U7XG5kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc2V0dGluZ3MtbGlzdCcpIS5hZGRFdmVudExpc3RlbmVyKCdmb2N1c291dCcsIChlOiBGb2N1c0V2ZW50KSA9PiB7XG4gIGNvbnN0IHRhcmdldCA9IGUudGFyZ2V0IGFzIEhUTUxFbGVtZW50O1xuICBpZiAodGFyZ2V0LmNsYXNzTGlzdD8uY29udGFpbnMoJ3NldHRpbmctaW5saW5lLWlucHV0JykpIHtcbiAgICBzZXRUaW1lb3V0KCgpID0+IHtcbiAgICAgIGlmIChza2lwTmV4dEJsdXJDb21taXQpIHsgc2tpcE5leHRCbHVyQ29tbWl0ID0gZmFsc2U7IHJldHVybjsgfVxuICAgICAgY29tbWl0RWRpdCgodGFyZ2V0IGFzIEhUTUxJbnB1dEVsZW1lbnQpLmRhdGFzZXQua2V5ISwgKHRhcmdldCBhcyBIVE1MSW5wdXRFbGVtZW50KS52YWx1ZSk7XG4gICAgfSwgNTApO1xuICB9XG59KTtcblxuLy8gQ29tbWl0IHNlbGVjdCBjaGFuZ2VzXG5kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc2V0dGluZ3MtbGlzdCcpIS5hZGRFdmVudExpc3RlbmVyKCdjaGFuZ2UnLCAoZTogRXZlbnQpID0+IHtcbiAgY29uc3QgdGFyZ2V0ID0gZS50YXJnZXQgYXMgSFRNTFNlbGVjdEVsZW1lbnQ7XG4gIGlmICh0YXJnZXQudGFnTmFtZSA9PT0gJ1NFTEVDVCcgJiYgdGFyZ2V0LmNsYXNzTGlzdD8uY29udGFpbnMoJ3NldHRpbmctaW5saW5lLXNlbGVjdCcpKSB7XG4gICAgY29tbWl0RWRpdCh0YXJnZXQuZGF0YXNldC5rZXkhLCB0YXJnZXQudmFsdWUpO1xuICB9XG59KTtcblxuLy8gLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tXG4vLyBJbml0XG4vLyAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS1cblxuYXN5bmMgZnVuY3Rpb24gaW5pdCgpOiBQcm9taXNlPHZvaWQ+IHtcbiAgdHJ5IHtcbiAgICBzdGF0ZS5wYWxldHRlcyA9IGF3YWl0IGludm9rZTxQYWxldHRlSW5mb1tdPignbGlzdF9wYWxldHRlcycpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc29sZS5lcnJvcignRmFpbGVkIHRvIGxvYWQgcGFsZXR0ZXM6JywgZSk7XG4gIH1cbiAgcmVuZGVyU2V0dGluZ3MoKTtcbiAgcmVuZGVyRGlhZ25vc3RpY3MoKTtcbiAgcmVuZGVyQmF0Y2goKTtcbiAgcmVuZGVyU2hlZXQoKTtcbn1cblxuLy8gQmF0Y2ggcGFuZWwgY2xpY2sgZGVsZWdhdGlvblxuZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2JhdGNoLWNvbnRlbnQnKSEuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoZTogRXZlbnQpID0+IHtcbiAgY29uc3QgdGFyZ2V0ID0gZS50YXJnZXQgYXMgSFRNTEVsZW1lbnQ7XG4gIGlmICh0YXJnZXQuaWQgPT09ICdiYXRjaC1hZGQtZmlsZXMnKSB7IGJhdGNoQWRkRmlsZXMoKTsgcmV0dXJuOyB9XG4gIGlmICh0YXJnZXQuaWQgPT09ICdiYXRjaC1jbGVhci1maWxlcycpIHsgc3RhdGUuYmF0Y2hGaWxlcyA9IFtdOyBzdGF0ZS5iYXRjaFJlc3VsdCA9IG51bGw7IHJlbmRlckJhdGNoKCk7IHJldHVybjsgfVxuICBpZiAodGFyZ2V0LmlkID09PSAnYmF0Y2gtY2hvb3NlLWRpcicpIHsgYmF0Y2hDaG9vc2VEaXIoKTsgcmV0dXJuOyB9XG4gIGlmICh0YXJnZXQuaWQgPT09ICdiYXRjaC1ydW4nKSB7IGJhdGNoUnVuKCk7IHJldHVybjsgfVxufSk7XG5cbi8vIFNoZWV0IHBhbmVsIGNsaWNrIGRlbGVnYXRpb25cbmRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzaGVldC1jb250ZW50JykhLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKGU6IEV2ZW50KSA9PiB7XG4gIGNvbnN0IHRhcmdldCA9IGUudGFyZ2V0IGFzIEhUTUxFbGVtZW50O1xuICBpZiAodGFyZ2V0LmNsYXNzTGlzdD8uY29udGFpbnMoJ3NoZWV0LW1vZGUtYnRuJykgJiYgIXRhcmdldC5jbGFzc0xpc3QuY29udGFpbnMoJ2dpZi1tb2RlLWJ0bicpKSB7XG4gICAgY29uc3QgbW9kZSA9IHRhcmdldC5kYXRhc2V0Lm1vZGUgYXMgJ2ZpeGVkJyB8ICdhdXRvJztcbiAgICBpZiAobW9kZSkgeyBzdGF0ZS5zaGVldE1vZGUgPSBtb2RlOyBzdGF0ZS5zaGVldFByZXZpZXcgPSBudWxsOyByZW5kZXJTaGVldCgpOyB9XG4gICAgcmV0dXJuO1xuICB9XG4gIGlmICh0YXJnZXQuY2xhc3NMaXN0Py5jb250YWlucygnZ2lmLW1vZGUtYnRuJykpIHtcbiAgICBjb25zdCBnaWZNb2RlID0gdGFyZ2V0LmRhdGFzZXQuZ2lmTW9kZSBhcyAncm93JyB8ICdhbGwnO1xuICAgIGlmIChnaWZNb2RlKSB7IHN0YXRlLmdpZk1vZGUgPSBnaWZNb2RlOyBzdGF0ZS5naWZQcmV2aWV3VXJsID0gbnVsbDsgcmVuZGVyU2hlZXQoKTsgfVxuICAgIHJldHVybjtcbiAgfVxuICBpZiAodGFyZ2V0LmlkID09PSAnc2hlZXQtbm8tbm9ybWFsaXplJykgeyBzdGF0ZS5zaGVldENvbmZpZy5ub05vcm1hbGl6ZSA9ICFzdGF0ZS5zaGVldENvbmZpZy5ub05vcm1hbGl6ZTsgcmVuZGVyU2hlZXQoKTsgcmV0dXJuOyB9XG4gIGlmICh0YXJnZXQuaWQgPT09ICdzaGVldC1wcmV2aWV3LWJ0bicpIHsgc2hlZXRQcmV2aWV3QWN0aW9uKCk7IHJldHVybjsgfVxuICBpZiAodGFyZ2V0LmlkID09PSAnc2hlZXQtcHJvY2Vzcy1idG4nKSB7IHNoZWV0UHJvY2Vzc0FjdGlvbigpOyByZXR1cm47IH1cbiAgaWYgKHRhcmdldC5pZCA9PT0gJ3NoZWV0LXNhdmUtdGlsZXMtYnRuJykgeyBzaGVldFNhdmVUaWxlc0FjdGlvbigpOyByZXR1cm47IH1cbiAgaWYgKHRhcmdldC5pZCA9PT0gJ2dpZi1wcmV2aWV3LWJ0bicpIHsgZ2lmUHJldmlld0FjdGlvbigpOyByZXR1cm47IH1cbiAgaWYgKHRhcmdldC5pZCA9PT0gJ2dpZi1leHBvcnQtYnRuJykgeyBnaWZFeHBvcnRBY3Rpb24oKTsgcmV0dXJuOyB9XG59KTtcblxuaW5pdCgpO1xuIgogIF0sCiAgIm1hcHBpbmdzIjogIjtBQW1DQSxNQUFRLFdBQVcsT0FBTyxVQUFVO0FBQ3BDLE1BQVEsTUFBTSxZQUFZLE1BQU0sZUFBZSxPQUFPLFVBQVU7QUFnSmhFLElBQU0s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QUFNLE9BQU8sVUFBVSxTQUFTLGNBQWMsR0FBRztBQUM5RixVQUFNLE9BQU8sT0FBTyxRQUFRO0FBQzVCLFFBQUksTUFBTTtBQUFFLFlBQU0sWUFBWTtBQUFNLFlBQU0sZUFBZTtBQUFNLGtCQUFZO0FBQUEsSUFBRztBQUM5RTtBQUFBLEVBQ0Y7QUFDQSxNQUFJLE9BQU8sV0FBVyxTQUFTLGNBQWMsR0FBRztBQUM5QyxVQUFNLFVBQVUsT0FBTyxRQUFRO0FBQy9CLFFBQUksU0FBUztBQUFFLFlBQU0sVUFBVTtBQUFTLFlBQU0sZ0JBQWdCO0FBQU0sa0JBQVk7QUFBQSxJQUFHO0FBQ25GO0FBQUEsRUFDRjtBQUNBLE1BQUksT0FBTyxPQUFPLHNCQUFzQjtBQUFFLFVBQU0sWUFBWSxlQUFlLE1BQU0sWUFBWTtBQUFhLGdCQUFZO0FBQUc7QUFBQSxFQUFRO0FBQ2pJLE1BQUksT0FBTyxPQUFPLHFCQUFxQjtBQUFFLHVCQUFtQjtBQUFHO0FBQUEsRUFBUTtBQUN2RSxNQUFJLE9BQU8sT0FBTyxxQkFBcUI7QUFBRSx1QkFBbUI7QUFBRztBQUFBLEVBQVE7QUFDdkUsTUFBSSxPQUFPLE9BQU8sd0JBQXdCO0FBQUUseUJBQXFCO0FBQUc7QUFBQSxFQUFRO0FBQzVFLE1BQUksT0FBTyxPQUFPLG1CQUFtQjtBQUFFLHFCQUFpQjtBQUFHO0FBQUEsRUFBUTtBQUNuRSxNQUFJLE9BQU8sT0FBTyxrQkFBa0I7QUFBRSxvQkFBZ0I7QUFBRztBQUFBLEVBQVE7QUFBQSxDQUNsRTtBQUVELEtBQUs7IiwKICAiZGVidWdJZCI6ICI4RDM5RTEwRTVFRjhBMDNDNjQ3NTZFMjE2NDc1NkUyMSIsCiAgIm5hbWVzIjogW10KfQ== diff --git a/src/anim.rs b/src/anim.rs new file mode 100644 index 0000000..f4155c2 --- /dev/null +++ b/src/anim.rs @@ -0,0 +1,460 @@ +//! Animated-GIF support: decode, run the pipeline coherently across frames, +//! re-encode with the original timing. +//! +//! Coherence is the whole point. Running the single-image pipeline per frame +//! would detect a slightly different grid, background, and palette on every +//! frame, and the animation would shimmer. Instead, everything global is +//! decided once and shared: the grid comes from the first frame, the +//! background color is resolved once, and one palette is extracted from a +//! histogram merged across all frames. Only the genuinely per-frame work +//! (AA removal, mask building, block snapping, palette snapping) runs per +//! frame. + +use std::io::Cursor; + +use image::codecs::gif::{GifDecoder, GifEncoder, Repeat}; +use image::{AnimationDecoder, Delay, Frame, RgbaImage}; +use tracing::info; + +use crate::color::palette_match::PaletteMatcher; +use crate::error::{NormalizeError, Result}; +use crate::image_util::histogram::ColorHistogram; +use crate::pipeline::background::DetectedBackground; +use crate::pipeline::{ + aa_removal, background, dither, downscale, grid_detect, integer_upscale, quantize, DitherPair, + DownscaleMode, Grid, PipelineConfig, PipelineDiagnostics, PipelineState, +}; + +/// A decoded animation: full composited frames plus their timing. +pub struct AnimatedImage { + /// Composited full-canvas RGBA frames (the decoder resolves GIF + /// disposal methods; every frame is the complete picture). + pub frames: Vec, + /// Per-frame delay as (numerator, denominator) milliseconds, exactly as + /// decoded, so re-encoding preserves the original timing. + pub delays: Vec<(u32, u32)>, +} + +/// The result of the animated pipeline: processed frames, original timing, +/// and the shared grid plus first-frame diagnostics. +pub struct AnimatedState { + pub frames: Vec, + pub delays: Vec<(u32, u32)>, + /// Input canvas dimensions (all frames share them). + pub original_width: u32, + pub original_height: u32, + /// The one grid applied to every frame (detected on the first). + pub grid: Option, + /// First-frame diagnostics, with color counts computed across ALL + /// frames (merged histograms). + pub diagnostics: PipelineDiagnostics, +} + +/// Whether a byte buffer starts with a GIF signature. +pub fn is_gif(bytes: &[u8]) -> bool { + bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") +} + +fn load_err(source: image::ImageError) -> NormalizeError { + NormalizeError::ImageLoad { + path: "".into(), + source, + } +} + +fn encode_err(source: image::ImageError) -> NormalizeError { + NormalizeError::Encode { + path: "".into(), + source, + } +} + +/// Decode a GIF (animated or not) into composited full frames with delays. +pub fn decode_gif(bytes: &[u8]) -> Result { + let decoder = GifDecoder::new(Cursor::new(bytes)).map_err(load_err)?; + let raw = decoder.into_frames().collect_frames().map_err(load_err)?; + + let mut frames = Vec::with_capacity(raw.len()); + let mut delays = Vec::with_capacity(raw.len()); + for frame in raw { + delays.push(frame.delay().numer_denom_ms()); + frames.push(frame.into_buffer()); + } + if frames.is_empty() { + return Err(NormalizeError::InvalidInput( + "GIF contains no frames".to_string(), + )); + } + let (w, h) = (frames[0].width(), frames[0].height()); + if frames.iter().any(|f| f.width() != w || f.height() != h) { + return Err(NormalizeError::InvalidInput( + "GIF frames decoded to inconsistent dimensions".to_string(), + )); + } + Ok(AnimatedImage { frames, delays }) +} + +/// Encode frames as an infinitely looping animated GIF, preserving +/// per-frame delays. Pure in-memory; the caller owns any file I/O. +pub fn encode_gif(frames: &[RgbaImage], delays: &[(u32, u32)]) -> Result> { + let mut bytes = Vec::new(); + { + let mut encoder = GifEncoder::new(&mut bytes); + encoder.set_repeat(Repeat::Infinite).map_err(encode_err)?; + for (i, image) in frames.iter().enumerate() { + let (num, den) = delays.get(i).copied().unwrap_or((100, 1)); + let frame = + Frame::from_parts(image.clone(), 0, 0, Delay::from_numer_denom_ms(num, den)); + encoder.encode_frame(frame).map_err(encode_err)?; + } + } + Ok(bytes) +} + +/// Run the normalization pipeline over an animation with shared decisions: +/// one grid (from the first frame), one background color (resolved once on +/// the first frame), one palette (merged histogram across all frames). +/// Stage order and mask semantics mirror [`crate::pipeline::run_pipeline`]. +pub fn run_pipeline_animated( + anim: AnimatedImage, + config: &PipelineConfig, +) -> Result { + let AnimatedImage { frames, delays } = anim; + let Some(first) = frames.first() else { + return Err(NormalizeError::InvalidInput( + "animated GIF has no frames".to_string(), + )); + }; + let (original_width, original_height) = (first.width(), first.height()); + if frames + .iter() + .any(|f| f.width() != original_width || f.height() != original_height) + { + return Err(NormalizeError::InvalidInput( + "animated GIF frames have inconsistent dimensions".to_string(), + )); + } + + // Stage 1 (once): grid detection on the first frame. The same grid is + // applied to every frame so blocks can never drift between frames; when + // detection declines, no frame is snapped — same as the still path. + let mut probe = PipelineState::new(first.clone()); + if !config.grid.skip { + grid_detect::detect_grid(&mut probe, &config.grid)?; + } else if let Some(grid) = grid_detect::override_grid(&config.grid) { + grid.validate(original_width, original_height)?; + probe.grid = Some(grid); + } + let shared_grid = probe.grid; + let grid_diag = probe.diagnostics; + + let used_snap_mode = config.downscale.mode == DownscaleMode::Snap; + + // Stages 2-5 per frame. The background COLOR is resolved once, on the + // first frame post-AA (per-frame re-detection could flicker between + // colors); the mask is rebuilt per frame from that shared color, and + // chroma keys mask each frame as normal. + let mut shared_bg: Option = None; + let mut states: Vec = Vec::with_capacity(frames.len()); + for (i, frame) in frames.into_iter().enumerate() { + let mut state = PipelineState::new(frame); + state.grid = shared_grid; + + // Stage 2: AA removal. + if !config.aa.skip { + aa_removal::remove_aa(&mut state, &config.aa)?; + } + + // Stage 3: background mask (shared color) + chroma keys, at full + // resolution. Nothing is cleared yet — masked pixels don't vote in + // the snap, and the mask is applied afterwards. + if i == 0 && config.background.enabled { + shared_bg = background::resolve_background(&state.image, &config.background); + if shared_bg.is_none() { + info!("No dominant border color detected, skipping background removal"); + } + } + let mut removal_mask: Option> = None; + if let Some(ref bg) = shared_bg { + removal_mask = Some(background::build_bg_mask( + &state.image, + bg, + &config.background, + )); + state.diagnostics.detected_bg = Some([bg.color[0], bg.color[1], bg.color[2]]); + state.diagnostics.bg_coverage = Some(bg.coverage); + } + if !config.background.chroma_keys.is_empty() { + let chroma = background::build_chroma_mask( + &state.image, + &config.background.chroma_keys, + config.background.chroma_tolerance, + config.background.defringe, + ); + removal_mask = Some(match removal_mask { + Some(mut m) => { + for (a, b) in m.iter_mut().zip(chroma) { + *a |= b; + } + m + } + None => chroma, + }); + } + state.bg_mask = removal_mask; + + // Stage 4: grid normalization (mask-aware). + if state.grid.is_some() { + downscale::majority_vote_downscale(&mut state, &config.downscale)?; + } + + // Stage 5: apply the background mask. + if let Some(mask) = state.bg_mask.take() { + let removed = background::apply_bg_mask(&mut state.image, &mask); + state.diagnostics.bg_pixels_removed = Some(removed as u64); + } + + states.push(state); + } + + // Diagnostics: the first frame's per-frame stats plus the grid + // detection results from the probe run. + let mut diagnostics = states[0].diagnostics.clone(); + diagnostics.grid_confidence = grid_diag.grid_confidence; + diagnostics.grid_scores = grid_diag.grid_scores; + diagnostics.grid_best_guess = grid_diag.grid_best_guess; + + // Stage 6 (once): dither detection on the first post-snap frame; the + // pairs pin quantization centroids exactly as in the still path. + let mut dither_pairs: Vec = Vec::new(); + if config.dither.enabled && !config.quantize.skip { + let grid_for_dither = if used_snap_mode { shared_grid } else { None }; + dither_pairs = + dither::detect_dither_pairs(&states[0].image, grid_for_dither.as_ref(), &config.dither); + if !dither_pairs.is_empty() { + info!(pairs = dither_pairs.len(), "Detected dither pairs"); + } + diagnostics.dither_pairs = dither_pairs + .iter() + .map(|p| (p.a_rgb, p.b_rgb, p.alternating)) + .collect(); + } + + // Stage 7 (once): shared quantization. ONE histogram merged across all + // post-snap frames feeds palette extraction, and ONE matcher snaps + // every frame, so colors cannot flicker between per-frame palettes. + let mut frames: Vec = states.into_iter().map(|s| s.image).collect(); + if !config.quantize.skip { + let merged = merged_histogram(&frames); + let unique_before = merged.unique_colors(); + if let Some((palette_oklab, palette_rgba)) = + quantize::resolve_palette(&merged, &config.quantize, &dither_pairs)? + { + let mut matcher = PaletteMatcher::from_parts(palette_oklab, palette_rgba); + quantize::warn_collapsed_dither_pairs(&mut matcher, &dither_pairs); + for frame in &mut frames { + matcher.snap_image(frame); + } + let unique_after = merged_histogram(&frames).unique_colors(); + diagnostics.unique_colors_before = Some(unique_before as u32); + diagnostics.unique_colors_after = Some(unique_after as u32); + info!(colors = unique_after, "Quantization complete (shared)"); + } + } + + // Stage 8: output sizing, identical per frame (integer re-upscale for + // reduced modes, then explicit target dimensions). + let frames = frames + .into_iter() + .map(|f| finalize_output_size(f, shared_grid, used_snap_mode, config)) + .collect(); + + Ok(AnimatedState { + frames, + delays, + original_width, + original_height, + grid: shared_grid, + diagnostics, + }) +} + +/// Merged color histogram across all frames (counts summed). +fn merged_histogram(frames: &[RgbaImage]) -> ColorHistogram { + ColorHistogram::from_pixels(frames.iter().flat_map(|f| f.pixels().map(|p| p.0))) +} + +/// Mirror of `run_pipeline` stage 8 for one frame: reduced modes get the +/// crisp integer re-upscale unless `logical_output`; explicit output +/// dimensions always win as a final Nearest resize. +fn finalize_output_size( + mut image: RgbaImage, + grid: Option, + used_snap_mode: bool, + config: &PipelineConfig, +) -> RgbaImage { + if !used_snap_mode && !config.downscale.logical_output { + if let Some(grid) = grid { + let (fx, fy) = grid.upscale_factors(); + if fx > 1 || fy > 1 { + image = integer_upscale(&image, fx, fy); + } + } + } + let out_w = config.output_width.unwrap_or(image.width()); + let out_h = config.output_height.unwrap_or(image.height()); + if image.width() != out_w || image.height() != out_h { + image = image::imageops::resize(&image, out_w, out_h, image::imageops::FilterType::Nearest); + } + image +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::QuantizeConfig; + use image::Rgba; + + const BASE: [Rgba; 4] = [ + Rgba([255, 0, 0, 255]), + Rgba([0, 255, 0, 255]), + Rgba([0, 0, 255, 255]), + Rgba([255, 255, 0, 255]), + ]; + const SQUARE: Rgba = Rgba([30, 30, 30, 255]); + + /// One 8x8-cell frame at pitch 4: deterministic per-cell colors plus a + /// 2x2-cell dark square whose position moves with the frame index. + fn synthetic_frame(index: u32) -> RgbaImage { + let (cells, pitch) = (8u32, 4u32); + let size = cells * pitch; + let mut img = RgbaImage::new(size, size); + for y in 0..size { + for x in 0..size { + let (cx, cy) = ((x / pitch) as usize, (y / pitch) as usize); + img.put_pixel(x, y, BASE[(cy * 31 + cx * 17 + cy * cx * 7) % BASE.len()]); + } + } + let sq_cx = 1 + index; // moving square at cells (sq_cx..sq_cx+2, 2..4) + for y in (2 * pitch)..(4 * pitch) { + for x in (sq_cx * pitch)..((sq_cx + 2) * pitch) { + img.put_pixel(x, y, SQUARE); + } + } + img + } + + fn synthetic_anim() -> AnimatedImage { + AnimatedImage { + frames: (0..3).map(synthetic_frame).collect(), + delays: vec![(100, 1), (200, 1), (50, 1)], + } + } + + #[test] + fn test_animation_shares_grid_and_palette() { + let config = PipelineConfig { + quantize: QuantizeConfig { + num_colors: Some(4), + seed: 3, + ..Default::default() + }, + ..Default::default() + }; + let state = run_pipeline_animated(synthetic_anim(), &config).unwrap(); + + assert_eq!(state.frames.len(), 3, "frame count preserved"); + assert_eq!(state.delays, vec![(100, 1), (200, 1), (50, 1)]); + + let grid = state.grid.expect("grid detected once on the first frame"); + assert!( + (grid.pitch_x - 4.0).abs() < 0.05, + "expected pitch ~4, got {}", + grid.pitch_x + ); + + // The shared palette must hold across the whole animation: the + // UNION of unique colors over all frames fits the budget. Five + // input colors quantized per frame could keep different survivors + // per frame and blow past it. + let mut union = std::collections::HashSet::new(); + for frame in &state.frames { + for p in frame.pixels() { + union.insert(p.0); + } + } + assert!( + union.len() <= 4, + "union of colors across frames must fit the budget, got {}", + union.len() + ); + } + + #[test] + fn test_no_snap_when_detection_declines() { + // Uniform frames give detection nothing; no frame may be snapped + // and the input must pass through unscathed. + let frames: Vec = (0..2) + .map(|_| RgbaImage::from_pixel(16, 16, Rgba([128, 128, 128, 255]))) + .collect(); + let anim = AnimatedImage { + frames: frames.clone(), + delays: vec![(100, 1), (100, 1)], + }; + let config = PipelineConfig { + quantize: QuantizeConfig { + skip: true, + ..Default::default() + }, + ..Default::default() + }; + let state = run_pipeline_animated(anim, &config).unwrap(); + assert!(state.grid.is_none()); + assert_eq!(state.frames, frames); + } + + #[test] + fn test_delays_and_pixels_survive_encode_decode_roundtrip() { + let anim = synthetic_anim(); + let bytes = encode_gif(&anim.frames, &anim.delays).unwrap(); + assert!(is_gif(&bytes)); + + let decoded = decode_gif(&bytes).unwrap(); + assert_eq!(decoded.frames.len(), 3); + for (i, (orig, dec)) in anim.delays.iter().zip(&decoded.delays).enumerate() { + assert_eq!( + orig.0 as u64 * dec.1 as u64, + dec.0 as u64 * orig.1 as u64, + "frame {} delay must survive the roundtrip ({:?} vs {:?})", + i, + orig, + dec + ); + } + // Fully opaque frames with few colors encode with an exact palette, + // so decoding must reproduce the composited full frames verbatim. + for (i, (orig, dec)) in anim.frames.iter().zip(&decoded.frames).enumerate() { + assert_eq!(orig, dec, "frame {} pixels must roundtrip exactly", i); + } + } + + #[test] + fn test_encode_is_deterministic() { + let anim = synthetic_anim(); + let a = encode_gif(&anim.frames, &anim.delays).unwrap(); + let b = encode_gif(&anim.frames, &anim.delays).unwrap(); + assert_eq!(a, b, "same frames must encode to identical bytes"); + } + + #[test] + fn test_empty_animation_rejected() { + let anim = AnimatedImage { + frames: Vec::new(), + delays: Vec::new(), + }; + assert!(matches!( + run_pipeline_animated(anim, &PipelineConfig::default()), + Err(NormalizeError::InvalidInput(_)) + )); + } +} diff --git a/src/batch.rs b/src/batch.rs index 79f758a..547424d 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -1,123 +1,379 @@ //! Batch processing: normalize multiple images in parallel. +//! +//! The library does no terminal I/O here — callers supply a progress sink +//! (the CLI wires indicatif, pixfix wires app events), and every file's +//! outcome is itemized in the result so front ends can report precisely. use std::path::{Path, PathBuf}; -use anyhow::Result; -use indicatif::{ProgressBar, ProgressStyle}; -use rayon::prelude::*; - -use crate::image_util::io::{load_image, save_image}; +use crate::error::{NormalizeError, Result}; +use crate::image_util::io::{load_image, save_image_atomic, OutputFormat}; +use crate::parallel::*; +use crate::paths::{derive_output_path, OutputNaming}; use crate::pipeline::{run_pipeline, PipelineConfig}; +use crate::report::FileStatus; + +/// Image extensions batch processing picks up (matched case-insensitively). +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "bmp", "webp"]; + +/// Progress event delivered to the caller's sink (possibly from worker +/// threads, in arbitrary order). +#[derive(Debug, Clone)] +pub enum BatchEvent { + Started { + total: usize, + }, + FileDone { + index: usize, + input: PathBuf, + output: PathBuf, + }, + FileSkipped { + index: usize, + input: PathBuf, + reason: String, + }, + FileFailed { + index: usize, + input: PathBuf, + error: String, + }, + Finished { + succeeded: usize, + failed: usize, + skipped: usize, + }, +} + +/// Batch behavior knobs. +#[derive(Debug, Clone)] +pub struct BatchOptions { + pub overwrite: bool, + /// Suffix appended to output stems (default "_normalized"; empty allowed). + pub suffix: String, + /// Recreate the inputs' relative directory structure under the output + /// directory instead of flattening to file stems. + pub preserve_dirs: bool, + pub format: OutputFormat, +} + +impl Default for BatchOptions { + fn default() -> Self { + Self { + overwrite: false, + suffix: "_normalized".to_string(), + preserve_dirs: false, + format: OutputFormat::Png, + } + } +} -/// Result of a batch processing run. +/// One file's outcome. +#[derive(Debug, Clone)] +pub struct BatchFileOutcome { + pub input: PathBuf, + pub output: Option, + pub status: FileStatus, + pub error: Option, +} + +/// Result of a batch run. Outcomes preserve input order. +#[derive(Debug)] pub struct BatchResult { - pub succeeded: u32, - pub failed: Vec<(PathBuf, String)>, + pub files: Vec, } -/// Resolve a glob pattern or directory path into a list of image files. -pub fn resolve_inputs(pattern: &str) -> Result> { +impl BatchResult { + pub fn succeeded(&self) -> usize { + self.count(FileStatus::Ok) + } + pub fn failed(&self) -> usize { + self.count(FileStatus::Failed) + } + pub fn skipped(&self) -> usize { + self.count(FileStatus::Skipped) + } + fn count(&self, status: FileStatus) -> usize { + self.files.iter().filter(|f| f.status == status).count() + } +} + +/// Resolved batch inputs plus the base directory relative paths are +/// computed against for `preserve_dirs`. +#[derive(Debug)] +pub struct ResolvedInputs { + pub base: PathBuf, + pub files: Vec, +} + +/// Resolve a glob pattern or directory path into a sorted list of image +/// files. Extensions match case-insensitively (`.PNG` works). +pub fn resolve_inputs(pattern: &str) -> Result { let path = Path::new(pattern); - // If it's a directory, glob for common image extensions inside it + // Directory: every image file directly inside it. if path.is_dir() { let mut files = Vec::new(); - for ext in &["png", "jpg", "jpeg", "gif", "bmp", "webp"] { - let glob_pattern = format!("{}/*.{}", path.display(), ext); - for entry in glob::glob(&glob_pattern)? { - files.push(entry?); + let entries = std::fs::read_dir(path).map_err(|source| NormalizeError::Io { + path: path.to_path_buf(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| NormalizeError::Io { + path: path.to_path_buf(), + source, + })?; + let p = entry.path(); + if p.is_file() && has_image_extension(&p) { + files.push(p); } } files.sort(); - return Ok(files); + return Ok(ResolvedInputs { + base: path.to_path_buf(), + files, + }); } - // Otherwise treat as a glob pattern - let mut files: Vec = glob::glob(pattern)? + // Otherwise treat as a glob pattern. + let mut files: Vec = glob::glob(pattern) + .map_err(|e| NormalizeError::InvalidInput(format!("bad glob pattern: {}", e)))? .filter_map(|e| e.ok()) .filter(|p| p.is_file()) .collect(); files.sort(); - Ok(files) + Ok(ResolvedInputs { + base: glob_base(pattern), + files, + }) +} + +fn has_image_extension(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| { + let lower = e.to_ascii_lowercase(); + IMAGE_EXTENSIONS.contains(&lower.as_str()) + }) + .unwrap_or(false) +} + +/// The non-glob directory prefix of a pattern ("sprites/**/*.png" -> +/// "sprites"), used as the base for relative output paths. +fn glob_base(pattern: &str) -> PathBuf { + let mut base = PathBuf::new(); + for component in Path::new(pattern).iter() { + let s = component.to_string_lossy(); + if s.contains(['*', '?', '[', '{']) { + break; + } + base.push(component); + } + // The last non-glob component may be the filename itself; only keep + // directories. + if base.as_os_str().is_empty() { + PathBuf::from(".") + } else if base.is_dir() { + base + } else { + base.parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + } } -/// Compute the output path for a batch-processed file. -fn output_path_for(input: &Path, output_dir: &Path) -> PathBuf { - let stem = input - .file_stem() - .unwrap_or_default() - .to_string_lossy(); - let ext = input - .extension() - .unwrap_or_default() - .to_string_lossy(); - let ext = if ext.is_empty() { "png" } else { &ext }; - output_dir.join(format!("{}_normalized.{}", stem, ext)) +/// Plan every output path up front and reject colliding ones — flattening +/// `a/x.png` and `b/x.png` into one directory used to race two rayon +/// workers onto the same file. +pub fn plan_batch_outputs( + inputs: &ResolvedInputs, + output_dir: &Path, + opts: &BatchOptions, +) -> Result> { + let naming = OutputNaming { + suffix: &opts.suffix, + extension: opts.format.extension(), + }; + let outputs: Vec = inputs + .files + .iter() + .map(|input| { + if opts.preserve_dirs { + let rel_dir = input + .parent() + .and_then(|p| p.strip_prefix(&inputs.base).ok()) + .unwrap_or(Path::new("")); + derive_output_path(input, Some(&output_dir.join(rel_dir)), &naming) + } else { + derive_output_path(input, Some(output_dir), &naming) + } + }) + .collect(); + + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for (input, output) in inputs.files.iter().zip(&outputs) { + let key = output.to_string_lossy().to_lowercase(); + if let Some(prev) = seen.insert(key, input) { + return Err(NormalizeError::InvalidInput(format!( + "output collision: {} and {} both map to {} — use --preserve-dirs or a \ + different --suffix", + prev.display(), + input.display(), + output.display() + ))); + } + } + + Ok(outputs) } /// Process multiple images in parallel with a shared pipeline config. -pub fn run_batch( - inputs: &[PathBuf], +/// +/// `progress` receives events from worker threads; outcomes come back in +/// input order regardless. One bad file never aborts the run. +pub fn run_batch( + inputs: &ResolvedInputs, output_dir: &Path, config: &PipelineConfig, - overwrite: bool, -) -> Result { - let bar = ProgressBar::new(inputs.len() as u64); - bar.set_style( - ProgressStyle::default_bar() - .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}") - .unwrap() - .progress_chars("##-"), - ); - - let results: Vec> = inputs + opts: &BatchOptions, + progress: F, +) -> Result +where + F: Fn(BatchEvent) + Sync, +{ + let outputs = plan_batch_outputs(inputs, output_dir, opts)?; + + // Create any per-output directories up front (cheap, avoids races). + if opts.preserve_dirs { + for out in &outputs { + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent).map_err(|source| NormalizeError::Io { + path: parent.to_path_buf(), + source, + })?; + } + } + } + + progress(BatchEvent::Started { + total: inputs.files.len(), + }); + + let files: Vec = inputs + .files .par_iter() - .map(|input_path| { - let out = output_path_for(input_path, output_dir); - - if out.exists() && !overwrite { - bar.inc(1); - return Err(( - input_path.clone(), - format!("Output already exists: {}", out.display()), - )); + .zip(outputs.par_iter()) + .enumerate() + .map(|(index, (input, output))| { + if output.exists() && !opts.overwrite { + let reason = "output already exists (use --overwrite)".to_string(); + progress(BatchEvent::FileSkipped { + index, + input: input.clone(), + reason: reason.clone(), + }); + return BatchFileOutcome { + input: input.clone(), + output: Some(output.clone()), + status: FileStatus::Skipped, + error: Some(reason), + }; } - let result = (|| -> Result<(), String> { - let image = load_image(input_path) - .map_err(|e| format!("Failed to load: {}", e))?; - let state = run_pipeline(image, config) - .map_err(|e| format!("Pipeline failed: {}", e))?; - save_image(&state.image, &out) - .map_err(|e| format!("Failed to save: {}", e))?; - Ok(()) + let result = (|| -> Result<()> { + let image = load_image(input)?; + let state = run_pipeline(image, config)?; + save_image_atomic(&state.image, output, opts.format) })(); - bar.inc(1); - match result { Ok(()) => { - bar.set_message(format!( - "{}", - input_path.file_name().unwrap_or_default().to_string_lossy() - )); - Ok(()) + progress(BatchEvent::FileDone { + index, + input: input.clone(), + output: output.clone(), + }); + BatchFileOutcome { + input: input.clone(), + output: Some(output.clone()), + status: FileStatus::Ok, + error: None, + } + } + Err(e) => { + progress(BatchEvent::FileFailed { + index, + input: input.clone(), + error: e.to_string(), + }); + BatchFileOutcome { + input: input.clone(), + output: None, + status: FileStatus::Failed, + error: Some(e.to_string()), + } } - Err(e) => Err((input_path.clone(), e)), } }) .collect(); - bar.finish_with_message("done"); + let result = BatchResult { files }; + progress(BatchEvent::Finished { + succeeded: result.succeeded(), + failed: result.failed(), + skipped: result.skipped(), + }); - let mut succeeded = 0u32; - let mut failed = Vec::new(); - for r in results { - match r { - Ok(()) => succeeded += 1, - Err(e) => failed.push(e), + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inputs(paths: &[&str]) -> ResolvedInputs { + ResolvedInputs { + base: PathBuf::from("in"), + files: paths.iter().map(PathBuf::from).collect(), } } - Ok(BatchResult { succeeded, failed }) + #[test] + fn test_collision_detected() { + let ins = inputs(&["in/a/x.png", "in/b/x.png"]); + let err = plan_batch_outputs(&ins, Path::new("out"), &BatchOptions::default()); + assert!(matches!(err, Err(NormalizeError::InvalidInput(_)))); + } + + #[test] + fn test_preserve_dirs_resolves_collision() { + let ins = inputs(&["in/a/x.png", "in/b/x.png"]); + let opts = BatchOptions { + preserve_dirs: true, + ..Default::default() + }; + let outs = plan_batch_outputs(&ins, Path::new("out"), &opts).unwrap(); + assert_eq!(outs[0], PathBuf::from("out/a/x_normalized.png")); + assert_eq!(outs[1], PathBuf::from("out/b/x_normalized.png")); + } + + #[test] + fn test_custom_suffix_and_format() { + let ins = inputs(&["in/cat.jpg"]); + let opts = BatchOptions { + suffix: String::new(), + format: OutputFormat::Webp, + ..Default::default() + }; + let outs = plan_batch_outputs(&ins, Path::new("out"), &opts).unwrap(); + assert_eq!(outs[0], PathBuf::from("out/cat.webp")); + } + + #[test] + fn test_case_insensitive_extension() { + assert!(has_image_extension(Path::new("x.PNG"))); + assert!(has_image_extension(Path::new("x.WebP"))); + assert!(!has_image_extension(Path::new("x.txt"))); + } } diff --git a/src/cli.rs b/src/cli.rs index b88b4d2..15f9b98 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,12 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; +use crate::error::Result; +use crate::pipeline::options::{PaletteSource, PipelineOptions}; + #[derive(Parser, Debug)] #[command( - name = "normalize-pixelart", + name = "pixfix", about = "Normalize AI-generated pixel art into clean, grid-aligned game assets", version )] @@ -16,10 +19,19 @@ pub struct Cli { #[arg(short, long, global = true)] pub quiet: bool, - /// Path to config file (default: .normalize-pixelart.toml in cwd) + /// Path to config file (default: .pixfix.toml in cwd) #[arg(long, global = true)] pub config: Option, + /// Ignore any config file, including one auto-discovered in the cwd + #[arg(long, global = true, conflicts_with = "config", hide_short_help = true)] + pub no_config: bool, + + /// Emit one machine-readable JSON result document to stdout; progress + /// events go to stderr as NDJSON + #[arg(long, global = true)] + pub json: bool, + #[command(subcommand)] pub command: Commands, } @@ -28,6 +40,8 @@ pub struct Cli { pub enum Commands { /// Process a single image Process(ProcessArgs), + /// Inspect an image (grid, background, colors) without writing anything + Analyze(AnalyzeArgs), /// Batch process a directory of images Batch(BatchArgs), /// Process a sprite sheet (split, normalize, reassemble) @@ -39,6 +53,20 @@ pub enum Commands { Tui(TuiArgs), } +#[derive(Parser, Debug)] +pub struct AnalyzeArgs { + /// Input image path + pub input: PathBuf, + + /// Also preview sprite-sheet auto-splitting (reports the sprite count) + #[arg(long)] + pub sheet: bool, + + /// Pipeline options (grid overrides etc. are honored) + #[command(flatten)] + pub pipeline: PipelineFlags, +} + #[cfg(feature = "tui")] #[derive(Parser, Debug)] pub struct TuiArgs { @@ -51,78 +79,236 @@ pub struct TuiArgs { } /// Shared pipeline flags used by process, batch, and sheet commands. +/// +/// Flags deliberately carry no clap defaults: an unset flag stays `None` so +/// config-file values can fill in, and the hard defaults live in +/// `PipelineOptions::into_config` only. #[derive(Parser, Debug, Clone)] +#[command(group = clap::ArgGroup::new("palette_source") + .multiple(false) + .conflicts_with("no_quantize"))] pub struct PipelineFlags { // --- Grid Detection --- - /// Override auto-detected grid size (pixels per logical pixel) + /// Override auto-detected grid size in pixels per logical pixel + /// (fractional values like 10.667 are allowed) #[arg(long)] - pub grid_size: Option, + pub grid_size: Option, /// Override grid phase offset as "X,Y" (default: auto-detect) - #[arg(long, value_parser = parse_phase)] + #[arg(long, value_parser = parse_phase, requires = "grid_size", hide_short_help = true)] pub grid_phase: Option<(u32, u32)>, /// Skip grid detection entirely (requires --grid-size) - #[arg(long)] + #[arg(long, requires = "grid_size", hide_short_help = true)] pub no_grid_detect: bool, - /// Maximum grid size candidate to test during detection (default: 32) - #[arg(long, default_value = "32")] - pub max_grid_candidate: u32, + /// Maximum grid size candidate to test during detection [default: 32] + #[arg(long, hide_short_help = true)] + pub max_grid_candidate: Option, + + /// Multiply the final grid pitch (auto-detected or --grid-size) by an + /// integer. Detection finds the grid the generator rendered on; the + /// intended pixel-art resolution is often 2x or more coarser. Phase is + /// preserved so blocks stay aligned. No effect when detection declines. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + pub coarsen: Option, + + /// Confidence floor (0.0-1.0) below which detection declines to snap + /// and only reports its best guess [default: 0.35]. Lower it to accept + /// shakier grids on heavily anti-aliased images; 0 accepts anything. + #[arg(long, hide_short_help = true)] + pub min_confidence: Option, // --- Downscale --- - /// Downscale mode: snap (default, preserves dithering), center-weighted, majority-vote, center-pixel - #[arg(long, default_value = "snap")] - pub downscale_mode: String, + /// Downscale mode [default: snap, which preserves dithering] + #[arg(long, value_enum)] + pub downscale_mode: Option, + + /// Preserve original per-pixel alpha instead of binarizing each block + /// to fully opaque or fully transparent + #[arg(long, hide_short_help = true)] + pub keep_alpha: bool, + + /// For reduced-resolution modes: output the true logical size instead + /// of the default crisp integer re-upscale + #[arg(long, hide_short_help = true)] + pub logical_size: bool, // --- Anti-aliasing --- /// Enable AA removal with sensitivity 0.0-1.0 (off by default). - /// Lower = more aggressive. Only useful for cleanly upscaled pixel art. + /// Higher = more aggressive. Only useful for cleanly upscaled pixel art. #[arg(long)] pub aa_threshold: Option, + /// Maximum AA removal passes (wide AA ramps need several) [default: 3] + #[arg(long, requires = "aa_threshold", hide_short_help = true)] + pub aa_passes: Option, + // --- Color Quantization --- - /// Use a predefined palette (pico-8, sweetie-16, endesga-32, endesga-64, gameboy, nes) - #[arg(long)] + /// Use a predefined palette (see `palette list` for options) + #[arg(long, group = "palette_source", value_parser = parse_palette_name)] pub palette: Option, /// Load a custom palette from a .hex file (one color per line) - #[arg(long)] + #[arg(long, group = "palette_source")] pub palette_file: Option, /// Fetch a palette from Lospec by slug (e.g., "sweetie-16", "endesga-32") #[cfg(feature = "lospec")] - #[arg(long)] + #[arg(long, group = "palette_source")] pub lospec: Option, /// Auto-extract palette with this many colors - #[arg(long)] + #[arg(long, group = "palette_source")] pub colors: Option, /// Skip color quantization entirely #[arg(long)] pub no_quantize: bool, + /// RNG seed for palette extraction (same input + seed = identical output) + #[arg(long)] + pub seed: Option, + + /// Disable dither preservation: no pair detection, no centroid pinning, + /// no AA dither guard — quantization may flatten dithered shading + #[arg(long, hide_short_help = true)] + pub flatten_dither: bool, + // --- Background Removal --- /// Enable background detection and removal - #[arg(long)] + #[arg(long, overrides_with = "no_remove_bg")] pub remove_bg: bool, + /// Disable background removal (overrides a config file that enables it) + #[arg(long, overrides_with = "remove_bg", hide_short_help = true)] + pub no_remove_bg: bool, + /// Explicit background color as hex (e.g., "FF00FF" or "#FF00FF") - #[arg(long)] - pub bg_color: Option, + #[arg(long, value_parser = parse_hex_color_arg)] + pub bg_color: Option<[u8; 3]>, - /// Minimum fraction of border pixels for auto-detection (0.0-1.0, default: 0.4) - #[arg(long)] + /// Minimum fraction of border pixels for auto-detection (0.0-1.0) [default: 0.4] + #[arg(long, hide_short_help = true)] pub bg_threshold: Option, - /// Color tolerance for background matching in OKLAB space (default: 0.05) - #[arg(long)] + /// Color tolerance for background matching in OKLAB space [default: 0.05] + #[arg(long, hide_short_help = true)] pub bg_tolerance: Option, + /// Remove background with flood-fill from the border (the default) + #[arg(long, overrides_with = "no_flood_fill", hide_short_help = true)] + pub flood_fill: bool, + /// Use global replacement instead of flood-fill (removes interior bg too) - #[arg(long)] + #[arg(long, overrides_with = "flood_fill", hide_short_help = true)] pub no_flood_fill: bool, + + // --- Chroma keying --- + /// Remove this color everywhere in the image (green-screen style; no + /// border detection, no flood fill). Repeat for multiple keys. + #[arg(long, value_parser = parse_hex_color_arg)] + pub chroma_key: Vec<[u8; 3]>, + + /// OKLAB match tolerance for chroma keys [default: 0.05] + #[arg(long, requires = "chroma_key", hide_short_help = true)] + pub chroma_tolerance: Option, +} + +impl PipelineFlags { + /// Lower CLI flags into pipeline options. + /// + /// Does the palette-file / Lospec I/O up front so bad palettes fail before + /// any image is loaded. + pub fn to_options(&self) -> Result { + // Palette source precedence within the CLI layer: + // --palette-file > --lospec > --palette > --colors. + let palette = if let Some(ref path) = self.palette_file { + let colors = crate::color::palettes::load_hex_file(path)?; + Some(PaletteSource::Custom { + colors, + label: Some(format!("file:{}", path.display())), + }) + } else if let Some(slug) = self.lospec_slug() { + #[cfg(feature = "lospec")] + { + let pal = crate::color::lospec::fetch_lospec_palette(slug, false)?; + Some(PaletteSource::Custom { + colors: pal.colors, + label: Some(format!("lospec:{}", pal.slug)), + }) + } + #[cfg(not(feature = "lospec"))] + { + let _ = slug; + None + } + } else if let Some(ref name) = self.palette { + Some(PaletteSource::Named(name.clone())) + } else { + self.colors.map(PaletteSource::AutoExtract) + }; + + Ok(PipelineOptions { + grid_size: self.grid_size, + grid_phase: self.grid_phase, + max_grid_candidate: self.max_grid_candidate, + no_grid_detect: self.no_grid_detect.then_some(true), + coarsen: self.coarsen, + min_confidence: self.min_confidence, + downscale_mode: self.downscale_mode, + keep_alpha: self.keep_alpha.then_some(true), + logical_output: self.logical_size.then_some(true), + aa_threshold: self.aa_threshold, + aa_skip: self.aa_threshold.map(|_| false), + aa_passes: self.aa_passes, + palette, + no_quantize: self.no_quantize.then_some(true), + seed: self.seed, + flatten_dither: self.flatten_dither.then_some(true), + bg_enabled: self.bg_enabled(), + bg_color: self.bg_color, + bg_border_threshold: self.bg_threshold, + bg_color_tolerance: self.bg_tolerance, + bg_flood_fill: self.flood_fill_enabled(), + chroma_keys: (!self.chroma_key.is_empty()).then(|| self.chroma_key.clone()), + chroma_tolerance: self.chroma_tolerance, + output_width: None, + output_height: None, + }) + } + + /// Tri-state from the --remove-bg / --no-remove-bg pair. + pub fn bg_enabled(&self) -> Option { + if self.remove_bg { + Some(true) + } else if self.no_remove_bg { + Some(false) + } else { + None + } + } + + /// Tri-state from the --flood-fill / --no-flood-fill pair. + pub fn flood_fill_enabled(&self) -> Option { + if self.flood_fill { + Some(true) + } else if self.no_flood_fill { + Some(false) + } else { + None + } + } + + #[cfg(feature = "lospec")] + fn lospec_slug(&self) -> Option<&str> { + self.lospec.as_deref() + } + + #[cfg(not(feature = "lospec"))] + fn lospec_slug(&self) -> Option<&str> { + None + } } #[derive(Parser, Debug)] @@ -135,41 +321,56 @@ pub struct ProcessArgs { // --- Output Size --- /// Target output width (default: same as input) - #[arg(long)] + #[arg(long, hide_short_help = true)] pub target_width: Option, /// Target output height (default: same as input) - #[arg(long)] + #[arg(long, hide_short_help = true)] pub target_height: Option, + /// Output encoding (also sets the derived output extension) + #[arg(long, value_enum, default_value_t, hide_short_help = true)] + pub output_format: crate::image_util::io::OutputFormat, + + /// Also write a diagnostic overlay PNG: the source dimmed, blocks + /// tinted red by how contested their color vote was — a wrong pitch or + /// phase lights up instantly + #[arg(long, hide_short_help = true)] + pub debug_overlay: Option, + /// Overwrite output file if it exists - #[arg(long)] + #[arg(long, overrides_with = "no_overwrite")] pub overwrite: bool, + /// Never overwrite outputs (overrides a config file that enables it) + #[arg(long, overrides_with = "overwrite", hide_short_help = true)] + pub no_overwrite: bool, + /// Pipeline options #[command(flatten)] pub pipeline: PipelineFlags, } impl ProcessArgs { - /// Compute the output path. Defaults to `_normalized.png`. + /// Tri-state from the --overwrite / --no-overwrite pair. + pub fn overwrite_opt(&self) -> Option { + overwrite_tristate(self.overwrite, self.no_overwrite) + } + + /// Compute the output path. Defaults to `_normalized.` — + /// the OUTPUT format's extension, never the input's. pub fn output_path(&self) -> PathBuf { if let Some(ref out) = self.output { out.clone() } else { - let stem = self - .input - .file_stem() - .unwrap_or_default() - .to_string_lossy(); - let ext = self - .input - .extension() - .unwrap_or_default() - .to_string_lossy(); - let ext = if ext.is_empty() { "png" } else { &ext }; - self.input - .with_file_name(format!("{}_normalized.{}", stem, ext)) + crate::paths::derive_output_path( + &self.input, + None, + &crate::paths::OutputNaming { + suffix: "_normalized", + extension: self.output_format.extension(), + }, + ) } } } @@ -183,14 +384,38 @@ pub struct BatchArgs { pub output: PathBuf, /// Overwrite output files if they exist - #[arg(long)] + #[arg(long, overrides_with = "no_overwrite")] pub overwrite: bool, + /// Never overwrite outputs (overrides a config file that enables it) + #[arg(long, overrides_with = "overwrite", hide_short_help = true)] + pub no_overwrite: bool, + + /// Suffix appended to output stems (pass '' for none) [default: _normalized] + #[arg(long)] + pub suffix: Option, + + /// Recreate the inputs' relative directory structure under the output + /// directory (avoids name collisions with recursive globs) + #[arg(long)] + pub preserve_dirs: bool, + + /// Output encoding (also sets the output extension) + #[arg(long, value_enum, default_value_t)] + pub output_format: crate::image_util::io::OutputFormat, + /// Pipeline options #[command(flatten)] pub pipeline: PipelineFlags, } +impl BatchArgs { + /// Tri-state from the --overwrite / --no-overwrite pair. + pub fn overwrite_opt(&self) -> Option { + overwrite_tristate(self.overwrite, self.no_overwrite) + } +} + #[derive(Parser, Debug)] pub struct SheetArgs { /// Input sprite sheet image @@ -216,9 +441,13 @@ pub struct SheetArgs { pub margin: u32, /// Overwrite output file if it exists - #[arg(long)] + #[arg(long, overrides_with = "no_overwrite")] pub overwrite: bool, + /// Never overwrite outputs (overrides a config file that enables it) + #[arg(long, overrides_with = "overwrite", hide_short_help = true)] + pub no_overwrite: bool, + // --- Auto-split options --- /// Fraction of background pixels to classify a row/column as a separator (0.0-1.0, default: 0.90) #[arg(long)] @@ -246,24 +475,18 @@ pub struct SheetArgs { } impl SheetArgs { - /// Compute the output path. Defaults to `_normalized.png`. + /// Tri-state from the --overwrite / --no-overwrite pair. + pub fn overwrite_opt(&self) -> Option { + overwrite_tristate(self.overwrite, self.no_overwrite) + } + + /// Compute the output path. Defaults to `_normalized.png` — + /// always .png, never the input's extension. pub fn output_path(&self) -> PathBuf { if let Some(ref out) = self.output { out.clone() } else { - let stem = self - .input - .file_stem() - .unwrap_or_default() - .to_string_lossy(); - let ext = self - .input - .extension() - .unwrap_or_default() - .to_string_lossy(); - let ext = if ext.is_empty() { "png" } else { &ext }; - self.input - .with_file_name(format!("{}_normalized.{}", stem, ext)) + crate::paths::derive_output_path(&self.input, None, &Default::default()) } } } @@ -294,6 +517,14 @@ pub struct PaletteFetchArgs { /// Save palette as .hex file #[arg(long, short)] pub output: Option, + + /// Overwrite the output file if it exists + #[arg(long)] + pub overwrite: bool, + + /// Bypass the local cache and re-fetch from Lospec + #[arg(long)] + pub refresh: bool, } #[derive(Parser, Debug)] @@ -308,9 +539,49 @@ pub struct PaletteExtractArgs { /// Save palette as .hex file #[arg(long, short)] pub output: Option, + + /// Overwrite the output file if it exists + #[arg(long)] + pub overwrite: bool, +} + +fn overwrite_tristate(yes: bool, no: bool) -> Option { + if yes { + Some(true) + } else if no { + Some(false) + } else { + None + } +} + +/// Validate a built-in palette name at parse time. +fn parse_palette_name(s: &str) -> std::result::Result { + let slug = s.to_lowercase(); + if crate::color::palettes::ALL_PALETTES + .iter() + .any(|p| p.slug == slug) + { + Ok(slug) + } else { + let available: Vec<&str> = crate::color::palettes::ALL_PALETTES + .iter() + .map(|p| p.slug) + .collect(); + Err(format!( + "unknown palette '{}'. Available: {}", + s, + available.join(", ") + )) + } +} + +/// Validate a hex color at parse time. +fn parse_hex_color_arg(s: &str) -> std::result::Result<[u8; 3], String> { + crate::config::parse_hex_color(s) } -fn parse_phase(s: &str) -> Result<(u32, u32), String> { +fn parse_phase(s: &str) -> std::result::Result<(u32, u32), String> { let parts: Vec<&str> = s.split(',').collect(); if parts.len() != 2 { return Err("Phase must be in format 'X,Y' (e.g., '1,2')".to_string()); diff --git a/src/color/kmeans.rs b/src/color/kmeans.rs index c8864a7..d945ccc 100644 --- a/src/color/kmeans.rs +++ b/src/color/kmeans.rs @@ -1,113 +1,234 @@ +//! Weighted, seeded k-means clustering in OKLAB space. +//! +//! Points are unique colors weighted by their pixel counts — clustering the +//! histogram instead of raw pixels is exact, orders of magnitude faster on +//! post-snap images (a few hundred uniques), and keeps rare-but-structural +//! colors (1px outlines) from being dropped by subsampling. All randomness +//! comes from a caller-provided seed via ChaCha8, so identical inputs +//! produce identical palettes on every run and platform. + use palette::Oklab; -use rand::seq::SliceRandom; use rand::Rng; +use rand::SeedableRng; +use rand_chacha::ChaCha8Rng; use crate::color::oklab::oklab_distance_sq; -/// Run k-means++ clustering in OKLAB space. -/// -/// Returns a Vec of `k` centroid colors. +/// Configuration for a weighted k-means run. +#[derive(Debug, Clone)] +pub struct KmeansConfig { + /// Total number of centroids, INCLUDING pinned ones. + pub k: usize, + /// Maximum Lloyd's iterations. + pub max_iterations: usize, + /// RNG seed; the only source of randomness. + pub seed: u64, +} + +/// Result of a weighted k-means run. +#[derive(Debug, Clone)] +pub struct KmeansResult { + /// Centroids; pinned ones occupy indices `0..pinned.len()` unchanged. + pub centroids: Vec, + /// Per input point: index into `centroids`. + pub assignments: Vec, +} + +/// Weighted k-means++ / Lloyd's over unique colors. /// -/// Uses k-means++ initialization for better starting positions, -/// then runs Lloyd's algorithm until convergence. -pub fn kmeans_oklab(colors: &[Oklab], k: usize, max_iterations: usize) -> Vec { - if colors.is_empty() || k == 0 { - return Vec::new(); +/// - Init: k-means++ with a running min-distance array (O(n·k)), sampling +/// probability proportional to `weight · min_dist²`. +/// - `pinned` centroids participate in assignment and in the init distance +/// field but are never moved by the update step (dither-pair colors). +/// - Empty clusters are reseeded at the point with the largest weighted +/// distance to its centroid, so `k` requested colors means `k` effective +/// colors whenever the input has that many distinct points. +pub fn kmeans_weighted( + points: &[Oklab], + weights: &[u32], + pinned: &[Oklab], + config: &KmeansConfig, +) -> KmeansResult { + assert_eq!(points.len(), weights.len()); + if points.is_empty() || config.k == 0 { + return KmeansResult { + centroids: pinned.to_vec(), + assignments: Vec::new(), + }; } - let k = k.min(colors.len()); - // K-means++ initialization - let mut rng = rand::thread_rng(); - let mut centroids = kmeans_plus_plus_init(colors, k, &mut rng); + let k = config + .k + .clamp(pinned.len().min(config.k), points.len().max(pinned.len())); + let n_pinned = pinned.len().min(k); + let mut rng = ChaCha8Rng::seed_from_u64(config.seed); - let mut assignments = vec![0usize; colors.len()]; + // ---- k-means++ init with running min-distance array ---- + let mut centroids: Vec = pinned[..n_pinned].to_vec(); + let mut min_dist: Vec = if centroids.is_empty() { + vec![f32::MAX; points.len()] + } else { + points + .iter() + .map(|p| { + centroids + .iter() + .map(|c| oklab_distance_sq(*p, *c)) + .fold(f32::MAX, f32::min) + }) + .collect() + }; - for _iter in 0..max_iterations { - // Assignment step: assign each point to nearest centroid + if centroids.is_empty() { + // First centroid: the heaviest point (deterministic, and a sensible + // anchor for pixel art where the dominant color matters most). + let first = weights + .iter() + .enumerate() + .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(&a.0))) + .map(|(i, _)| i) + .unwrap(); + centroids.push(points[first]); + for (i, p) in points.iter().enumerate() { + min_dist[i] = oklab_distance_sq(*p, points[first]); + } + } + + while centroids.len() < k { + let total: f64 = points + .iter() + .enumerate() + .map(|(i, _)| weights[i] as f64 * min_dist[i] as f64) + .sum(); + if total <= 0.0 { + break; // All points coincide with existing centroids + } + let threshold = rng.random::() * total; + let mut cumulative = 0.0f64; + let mut chosen = points.len() - 1; + for i in 0..points.len() { + cumulative += weights[i] as f64 * min_dist[i] as f64; + if cumulative >= threshold { + chosen = i; + break; + } + } + let c = points[chosen]; + centroids.push(c); + for (i, p) in points.iter().enumerate() { + let d = oklab_distance_sq(*p, c); + if d < min_dist[i] { + min_dist[i] = d; + } + } + } + let k = centroids.len(); + + // ---- Lloyd's iterations ---- + let mut assignments = vec![0usize; points.len()]; + for _iter in 0..config.max_iterations { + // Assignment let mut changed = false; - for (i, color) in colors.iter().enumerate() { - let nearest = nearest_centroid(*color, ¢roids); + for (i, p) in points.iter().enumerate() { + let nearest = nearest_centroid(*p, ¢roids); if nearest != assignments[i] { assignments[i] = nearest; changed = true; } } - if !changed { - break; // Converged - } - - // Update step: recompute centroids as mean of assigned points - let mut sums_l = vec![0.0f64; k]; - let mut sums_a = vec![0.0f64; k]; - let mut sums_b = vec![0.0f64; k]; - let mut counts = vec![0u32; k]; - - for (i, color) in colors.iter().enumerate() { + // Update (free centroids only) + let mut sums = vec![(0.0f64, 0.0f64, 0.0f64, 0.0f64); k]; + for (i, p) in points.iter().enumerate() { let c = assignments[i]; - sums_l[c] += color.l as f64; - sums_a[c] += color.a as f64; - sums_b[c] += color.b as f64; - counts[c] += 1; + let w = weights[i] as f64; + sums[c].0 += p.l as f64 * w; + sums[c].1 += p.a as f64 * w; + sums[c].2 += p.b as f64 * w; + sums[c].3 += w; } - for c in 0..k { - if counts[c] > 0 { - let n = counts[c] as f64; + let mut reseeded = false; + for c in n_pinned..k { + if sums[c].3 > 0.0 { + let w = sums[c].3; centroids[c] = Oklab::new( - (sums_l[c] / n) as f32, - (sums_a[c] / n) as f32, - (sums_b[c] / n) as f32, + (sums[c].0 / w) as f32, + (sums[c].1 / w) as f32, + (sums[c].2 / w) as f32, ); + } else { + // Empty cluster: reseed at the point farthest (weighted) + // from its current centroid. + let far = points + .iter() + .enumerate() + .max_by(|(i, p), (j, q)| { + let di = weights[*i] as f64 + * oklab_distance_sq(**p, centroids[assignments[*i]]) as f64; + let dj = weights[*j] as f64 + * oklab_distance_sq(**q, centroids[assignments[*j]]) as f64; + di.total_cmp(&dj).then(j.cmp(i)) + }) + .map(|(i, _)| i) + .unwrap(); + centroids[c] = points[far]; + reseeded = true; } } + + if !changed && !reseeded { + break; // Converged + } } - centroids + KmeansResult { + centroids, + assignments, + } } -/// K-means++ initialization: choose k initial centroids with probability -/// proportional to squared distance from nearest existing centroid. -fn kmeans_plus_plus_init(colors: &[Oklab], k: usize, rng: &mut impl Rng) -> Vec { - let mut centroids = Vec::with_capacity(k); - - // First centroid: random point - let first = colors.choose(rng).copied().unwrap(); - centroids.push(first); - - // Subsequent centroids: weighted by distance to nearest existing centroid - for _ in 1..k { - let weights: Vec = colors - .iter() - .map(|c| { - centroids - .iter() - .map(|cent| oklab_distance_sq(*c, *cent)) - .fold(f32::MAX, f32::min) - }) - .collect(); - - let total: f32 = weights.iter().sum(); - if total <= 0.0 { - // All remaining points are at existing centroids - break; - } - - // Weighted random selection - let threshold = rng.gen::() * total; - let mut cumulative = 0.0f32; - let mut chosen = colors.len() - 1; - for (i, &w) in weights.iter().enumerate() { - cumulative += w; - if cumulative >= threshold { - chosen = i; - break; - } +/// Per cluster, the index of the member minimizing the weighted sum of +/// squared distances to the other members (tie-break: heavier point, then +/// lower index). `None` for clusters with no members. +pub fn weighted_medoids( + points: &[Oklab], + weights: &[u32], + assignments: &[usize], + k: usize, +) -> Vec> { + let mut members: Vec> = vec![Vec::new(); k]; + for (i, &c) in assignments.iter().enumerate() { + if c < k { + members[c].push(i); } - centroids.push(colors[chosen]); } - centroids + members + .iter() + .map(|cluster| { + if cluster.is_empty() { + return None; + } + cluster + .iter() + .map(|&i| { + let cost: f64 = cluster + .iter() + .map(|&j| { + weights[j] as f64 * oklab_distance_sq(points[i], points[j]) as f64 + }) + .sum(); + (i, cost) + }) + .min_by(|a, b| { + a.1.total_cmp(&b.1) + .then(weights[b.0].cmp(&weights[a.0])) + .then(a.0.cmp(&b.0)) + }) + .map(|(i, _)| i) + }) + .collect() } /// Find the index of the nearest centroid to a given color. @@ -116,76 +237,124 @@ pub fn nearest_centroid(color: Oklab, centroids: &[Oklab]) -> usize { .iter() .enumerate() .min_by(|(_, a), (_, b)| { - oklab_distance_sq(color, **a) - .partial_cmp(&oklab_distance_sq(color, **b)) - .unwrap() + oklab_distance_sq(color, **a).total_cmp(&oklab_distance_sq(color, **b)) }) .map(|(i, _)| i) .unwrap_or(0) } -/// Subsample colors for faster k-means on large images. -/// Takes at most `max_samples` random samples from the input. -pub fn subsample(colors: &[Oklab], max_samples: usize) -> Vec { - if colors.len() <= max_samples { - return colors.to_vec(); - } - let mut rng = rand::thread_rng(); - let mut indices: Vec = (0..colors.len()).collect(); - indices.shuffle(&mut rng); - indices.truncate(max_samples); - indices.iter().map(|&i| colors[i]).collect() -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_kmeans_two_clusters() { - // Create two distinct clusters: around (0.3, 0, 0) and (0.7, 0, 0) - let mut colors = Vec::new(); - for _ in 0..50 { - colors.push(Oklab::new(0.3, 0.0, 0.0)); - } - for _ in 0..50 { - colors.push(Oklab::new(0.7, 0.0, 0.0)); + fn config(k: usize) -> KmeansConfig { + KmeansConfig { + k, + max_iterations: 50, + seed: 0, } + } - let centroids = kmeans_oklab(&colors, 2, 50); - assert_eq!(centroids.len(), 2); + #[test] + fn test_kmeans_two_clusters() { + let points = vec![Oklab::new(0.3, 0.0, 0.0), Oklab::new(0.7, 0.0, 0.0)]; + let weights = vec![50, 50]; + let result = kmeans_weighted(&points, &weights, &[], &config(2)); + assert_eq!(result.centroids.len(), 2); - // Centroids should be near 0.3 and 0.7 - let mut ls: Vec = centroids.iter().map(|c| c.l).collect(); - ls.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mut ls: Vec = result.centroids.iter().map(|c| c.l).collect(); + ls.sort_by(f32::total_cmp); assert!((ls[0] - 0.3).abs() < 0.05); assert!((ls[1] - 0.7).abs() < 0.05); } #[test] - fn test_kmeans_single_color() { - let colors = vec![Oklab::new(0.5, 0.1, -0.1); 100]; - let centroids = kmeans_oklab(&colors, 3, 50); - // Should still produce centroids (some may be duplicates) - assert!(!centroids.is_empty()); + fn test_same_seed_identical_output() { + let points: Vec = (0..200) + .map(|i| Oklab::new(i as f32 / 200.0, (i % 7) as f32 * 0.01, 0.0)) + .collect(); + let weights: Vec = (0..200).map(|i| 1 + (i % 13)).collect(); + let a = kmeans_weighted(&points, &weights, &[], &config(8)); + let b = kmeans_weighted(&points, &weights, &[], &config(8)); + assert_eq!(a.centroids, b.centroids); + assert_eq!(a.assignments, b.assignments); } #[test] - fn test_nearest_centroid() { - let centroids = vec![ + fn test_weighted_mean_exact() { + // k=1: centroid must be the weighted mean. + let points = vec![Oklab::new(0.0, 0.0, 0.0), Oklab::new(1.0, 0.0, 0.0)]; + let weights = vec![999, 1]; + let result = kmeans_weighted(&points, &weights, &[], &config(1)); + assert!((result.centroids[0].l - 0.001).abs() < 1e-4); + } + + #[test] + fn test_rare_structural_color_survives() { + // 500 pixels of one color, 3 pixels of a far-away outline color: + // with k=2 the outline must claim its own centroid (the defect the + // old 10k random subsample could not guarantee). + let points = vec![Oklab::new(0.8, 0.05, 0.05), Oklab::new(0.05, 0.0, 0.0)]; + let weights = vec![500, 3]; + let result = kmeans_weighted(&points, &weights, &[], &config(2)); + let has_outline = result + .centroids + .iter() + .any(|c| oklab_distance_sq(*c, points[1]) < 1e-4); + assert!(has_outline, "outline color lost: {:?}", result.centroids); + } + + #[test] + fn test_empty_cluster_reseeded() { + // Three distinct points, k=3: every centroid must land on a distinct + // point even though two points are heavy and one is far away. + let points = vec![ Oklab::new(0.2, 0.0, 0.0), - Oklab::new(0.8, 0.0, 0.0), + Oklab::new(0.21, 0.0, 0.0), + Oklab::new(0.9, 0.2, 0.1), ]; - assert_eq!(nearest_centroid(Oklab::new(0.25, 0.0, 0.0), ¢roids), 0); - assert_eq!(nearest_centroid(Oklab::new(0.75, 0.0, 0.0), ¢roids), 1); + let weights = vec![1000, 900, 1]; + let result = kmeans_weighted(&points, &weights, &[], &config(3)); + let mut sorted: Vec = result.centroids.iter().map(|c| c.l).collect(); + sorted.sort_by(f32::total_cmp); + sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + assert_eq!(sorted.len(), 3, "expected 3 distinct centroids"); } #[test] - fn test_subsample() { - let colors: Vec = (0..1000) - .map(|i| Oklab::new(i as f32 / 1000.0, 0.0, 0.0)) + fn test_pinned_centroids_never_move() { + let pin_a = Oklab::new(0.1, 0.02, 0.02); + let pin_b = Oklab::new(0.9, -0.02, 0.03); + let points: Vec = (0..50) + .map(|i| Oklab::new(0.3 + (i as f32) * 0.005, 0.0, 0.0)) .collect(); - let sampled = subsample(&colors, 100); - assert_eq!(sampled.len(), 100); + let weights = vec![1u32; 50]; + let result = kmeans_weighted(&points, &weights, &[pin_a, pin_b], &config(4)); + assert_eq!(result.centroids[0], pin_a); + assert_eq!(result.centroids[1], pin_b); + assert_eq!(result.centroids.len(), 4); + } + + #[test] + fn test_weighted_medoids_returns_member_indices() { + let points = vec![ + Oklab::new(0.1, 0.0, 0.0), + Oklab::new(0.15, 0.0, 0.0), + Oklab::new(0.8, 0.0, 0.0), + ]; + let weights = vec![10, 1, 5]; + let result = kmeans_weighted(&points, &weights, &[], &config(2)); + let medoids = weighted_medoids(&points, &weights, &result.assignments, 2); + for (c, m) in medoids.iter().enumerate() { + let idx = m.expect("cluster has members"); + assert_eq!(result.assignments[idx], c); + } + } + + #[test] + fn test_nearest_centroid() { + let centroids = vec![Oklab::new(0.2, 0.0, 0.0), Oklab::new(0.8, 0.0, 0.0)]; + assert_eq!(nearest_centroid(Oklab::new(0.25, 0.0, 0.0), ¢roids), 0); + assert_eq!(nearest_centroid(Oklab::new(0.75, 0.0, 0.0), ¢roids), 1); } } diff --git a/src/color/lospec.rs b/src/color/lospec.rs index 306102e..7ecd498 100644 --- a/src/color/lospec.rs +++ b/src/color/lospec.rs @@ -1,9 +1,18 @@ //! Fetch palettes from the Lospec palette database (https://lospec.com). //! -//! Palettes are cached locally at `~/.cache/normalize-pixelart/palettes/{slug}.hex` -//! to avoid repeated network requests. +//! Palettes are cached in the platform cache directory +//! (`dirs::cache_dir()/pixfix/palettes/{slug}.hex` — e.g. +//! `~/Library/Caches` on macOS, `~/.cache` on Linux) to avoid repeated +//! network requests. The cache doubles as user-editable .hex palette files. use std::path::PathBuf; +use std::time::Duration; + +use crate::error::{NormalizeError, Result}; + +/// Network timeout — a stalled connection must not hang the CLI (or a +/// worker thread in the desktop app). +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); /// Result of a Lospec palette fetch. pub struct LospecPalette { @@ -14,18 +23,41 @@ pub struct LospecPalette { /// Fetch a palette from Lospec by slug (e.g. "pico-8", "sweetie-16"). /// -/// Checks the local cache first. On cache miss, fetches from the Lospec API -/// and saves to cache for future use. -pub fn fetch_lospec_palette(slug: &str) -> Result { +/// Checks the local cache first (unless `refresh`). On cache miss, fetches +/// from the Lospec API and saves to cache for future use. +pub fn fetch_lospec_palette(slug: &str, refresh: bool) -> Result { + // The slug lands in both a URL and a cache file path — a value like + // "../../x" must never escape the cache directory. + if slug.is_empty() + || !slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(NormalizeError::Lospec(format!( + "invalid palette slug '{}': slugs are lowercase letters, digits, and hyphens", + slug + ))); + } + fetch_inner(slug, refresh).map_err(NormalizeError::Lospec) +} + +fn fetch_inner(slug: &str, refresh: bool) -> std::result::Result { // Check cache first - if let Some(cached) = load_cached(slug) { - return Ok(cached); + if !refresh { + if let Some(cached) = load_cached(slug) { + return Ok(cached); + } } // Fetch from API let url = format!("https://lospec.com/palette-list/{}.json", slug); - let response = ureq::get(&url) + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(FETCH_TIMEOUT)) + .build() + .into(); + let response = agent + .get(&url) .call() .map_err(|e| match &e { ureq::Error::StatusCode(404) => { @@ -42,10 +74,7 @@ pub fn fetch_lospec_palette(slug: &str) -> Result { let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| format!("Failed to parse Lospec JSON: {}", e))?; - let name = json["name"] - .as_str() - .unwrap_or(slug) - .to_string(); + let name = json["name"].as_str().unwrap_or(slug).to_string(); let colors_arr = json["colors"] .as_array() @@ -75,23 +104,26 @@ pub fn fetch_lospec_palette(slug: &str) -> Result { } /// Parse a hex color string (with or without '#') into [R, G, B]. -fn parse_hex_rgb(hex: &str) -> Result<[u8; 3], String> { +fn parse_hex_rgb(hex: &str) -> std::result::Result<[u8; 3], String> { let hex = hex.trim().trim_start_matches('#'); if hex.len() != 6 { - return Err(format!("Invalid hex color: '{}' (expected 6 hex digits)", hex)); + return Err(format!( + "Invalid hex color: '{}' (expected 6 hex digits)", + hex + )); } - let r = u8::from_str_radix(&hex[0..2], 16) - .map_err(|_| format!("Invalid hex color: '{}'", hex))?; - let g = u8::from_str_radix(&hex[2..4], 16) - .map_err(|_| format!("Invalid hex color: '{}'", hex))?; - let b = u8::from_str_radix(&hex[4..6], 16) - .map_err(|_| format!("Invalid hex color: '{}'", hex))?; + let r = + u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: '{}'", hex))?; + let g = + u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: '{}'", hex))?; + let b = + u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: '{}'", hex))?; Ok([r, g, b]) } /// Get the cache directory path. fn cache_dir() -> Option { - dirs::cache_dir().map(|d| d.join("normalize-pixelart").join("palettes")) + dirs::cache_dir().map(|d| d.join("pixfix").join("palettes")) } /// Try to load a cached palette. @@ -129,19 +161,24 @@ fn load_cached(slug: &str) -> Option { } /// Save a palette to the cache directory. -fn save_to_cache(slug: &str, name: &str, colors: &[[u8; 3]]) -> Result<(), String> { +fn save_to_cache(slug: &str, name: &str, colors: &[[u8; 3]]) -> std::result::Result<(), String> { let dir = cache_dir().ok_or_else(|| "Could not determine cache directory".to_string())?; std::fs::create_dir_all(&dir) .map_err(|e| format!("Failed to create cache directory: {}", e))?; let path = dir.join(format!("{}.hex", slug)); - let mut content = format!("; name: {}\n; fetched from https://lospec.com/palette-list/{}\n", name, slug); + let mut content = format!( + "; name: {}\n; fetched from https://lospec.com/palette-list/{}\n", + name, slug + ); for color in colors { - content.push_str(&format!("{:02X}{:02X}{:02X}\n", color[0], color[1], color[2])); + content.push_str(&format!( + "{:02X}{:02X}{:02X}\n", + color[0], color[1], color[2] + )); } - std::fs::write(&path, content) - .map_err(|e| format!("Failed to write cache file: {}", e))?; + std::fs::write(&path, content).map_err(|e| format!("Failed to write cache file: {}", e))?; Ok(()) } @@ -150,7 +187,10 @@ fn save_to_cache(slug: &str, name: &str, colors: &[[u8; 3]]) -> Result<(), Strin pub fn format_palette(palette: &LospecPalette) -> String { let mut out = format!("{} ({} colors)\n", palette.name, palette.colors.len()); for color in &palette.colors { - out.push_str(&format!(" #{:02X}{:02X}{:02X}\n", color[0], color[1], color[2])); + out.push_str(&format!( + " #{:02X}{:02X}{:02X}\n", + color[0], color[1], color[2] + )); } out } @@ -159,7 +199,10 @@ pub fn format_palette(palette: &LospecPalette) -> String { pub fn palette_to_hex_string(palette: &LospecPalette) -> String { let mut content = String::new(); for color in &palette.colors { - content.push_str(&format!("{:02X}{:02X}{:02X}\n", color[0], color[1], color[2])); + content.push_str(&format!( + "{:02X}{:02X}{:02X}\n", + color[0], color[1], color[2] + )); } content } diff --git a/src/color/oklab.rs b/src/color/oklab.rs index 8c3b185..e2960c6 100644 --- a/src/color/oklab.rs +++ b/src/color/oklab.rs @@ -25,6 +25,48 @@ pub fn oklab_distance(a: Oklab, b: Oklab) -> f32 { oklab_distance_sq(a, b).sqrt() } +/// hyAB distance: `|ΔL| + sqrt(Δa² + Δb²)`. +/// +/// For large color differences (the sparse-palette snapping regime) the +/// city-block lightness term tracks perception better than plain Euclidean, +/// which lets chroma distance swamp a big lightness error — the classic +/// failure is dark saturated blue snapping to black instead of navy. +/// Parameter-free, unlike an L-weighted Euclidean. +pub fn oklab_hyab(a: Oklab, b: Oklab) -> f32 { + let dl = (a.l - b.l).abs(); + let da = a.a - b.a; + let db = a.b - b.b; + dl + (da * da + db * db).sqrt() +} + +/// Check if a color lies between two others in OKLAB space (an +/// interpolation artifact such as an AA blend or a background fringe). +/// +/// Uses the triangle inequality: if dist(c1, p) + dist(p, c2) ≈ dist(c1, c2), +/// then p is roughly on the line segment between c1 and c2. `threshold` is +/// the sensitivity in [0, 1]; higher admits more deviation. +pub fn is_between_oklab(p: Oklab, c1: Oklab, c2: Oklab, threshold: f32) -> bool { + let d_c1_c2 = oklab_distance(c1, c2); + + // Colors must be sufficiently different for interpolation to be meaningful + if d_c1_c2 < 0.02 { + return false; + } + + let d_c1_p = oklab_distance(c1, p); + let d_p_c2 = oklab_distance(p, c2); + + // Triangle inequality deviation + let deviation = (d_c1_p + d_p_c2) / d_c1_c2 - 1.0; + + // Must not be too close to either endpoint + let ratio = d_c1_p / d_c1_c2; + let away_from_endpoints = ratio > 0.1 && ratio < 0.9; + + let max_deviation = threshold * 0.3; + deviation < max_deviation && away_from_endpoints +} + /// Compute the mean Oklab color from a slice of Oklab values. pub fn oklab_mean(colors: &[Oklab]) -> Oklab { if colors.is_empty() { @@ -97,4 +139,40 @@ mod tests { let mean = oklab_mean(&colors); assert!(oklab_variance(&colors, mean) < 1e-6); } + + #[test] + fn test_hyab_symmetry_and_zero() { + let a = rgba_to_oklab(Rgba([200, 30, 40, 255])); + let b = rgba_to_oklab(Rgba([20, 60, 220, 255])); + assert!(oklab_hyab(a, a) < 1e-6); + assert!((oklab_hyab(a, b) - oklab_hyab(b, a)).abs() < 1e-6); + } + + #[test] + fn test_hyab_equals_dl_when_chroma_matches() { + let a = Oklab::new(0.3, 0.1, -0.05); + let b = Oklab::new(0.8, 0.1, -0.05); + assert!((oklab_hyab(a, b) - 0.5).abs() < 1e-6); + } + + #[test] + fn test_is_between_exact_midpoint() { + let red = rgba_to_oklab(Rgba([255, 0, 0, 255])); + let blue = rgba_to_oklab(Rgba([0, 0, 255, 255])); + let blend = rgba_to_oklab(Rgba([127, 0, 127, 255])); + assert!(is_between_oklab(blend, red, blue, 0.5)); + } + + #[test] + fn test_is_not_between_same_color() { + let red = rgba_to_oklab(Rgba([255, 0, 0, 255])); + assert!(!is_between_oklab(red, red, red, 0.5)); + } + + #[test] + fn test_is_not_between_endpoint() { + let red = rgba_to_oklab(Rgba([255, 0, 0, 255])); + let blue = rgba_to_oklab(Rgba([0, 0, 255, 255])); + assert!(!is_between_oklab(red, red, blue, 0.5)); + } } diff --git a/src/color/palette_match.rs b/src/color/palette_match.rs index b90291c..63fceab 100644 --- a/src/color/palette_match.rs +++ b/src/color/palette_match.rs @@ -1,33 +1,78 @@ +//! Nearest-palette-color matching with hyAB distance and per-unique-color +//! memoization. + use image::{Rgba, RgbaImage}; use palette::Oklab; +use std::collections::HashMap; + +use crate::color::oklab::{oklab_hyab, rgba_to_oklab}; + +/// Snaps pixels to a fixed palette. +/// +/// Distance is hyAB (see [`oklab_hyab`]): in the sparse-palette regime the +/// city-block lightness term tracks perception better than Euclidean OKLAB. +/// Lookups are memoized per unique RGB — post-snap images have a few hundred +/// uniques, so the expensive sRGB→OKLAB conversion and palette scan run per +/// unique color, not per pixel. +/// +/// Alpha policy: pixels with alpha 0 are untouched; all others are +/// color-matched with their alpha preserved. There is no transparent palette +/// slot — transparency only ever comes from input alpha and background +/// removal. +pub struct PaletteMatcher { + palette_oklab: Vec, + palette_rgba: Vec>, + cache: HashMap<[u8; 3], usize>, +} -use crate::color::oklab::{oklab_distance_sq, rgba_to_oklab}; - -/// Snap every pixel in the image to the nearest color in the palette. -/// Uses OKLAB distance for perceptually accurate matching. -/// Preserves the original alpha channel. -pub fn snap_to_palette(image: &mut RgbaImage, palette_oklab: &[Oklab], palette_rgba: &[Rgba]) { - assert_eq!(palette_oklab.len(), palette_rgba.len()); +impl PaletteMatcher { + pub fn new(palette_rgb: &[[u8; 3]]) -> Self { + Self::from_parts(palette_to_oklab(palette_rgb), palette_to_rgba(palette_rgb)) + } - for pixel in image.pixels_mut() { - if pixel[3] == 0 { - continue; // Skip transparent pixels + pub fn from_parts(palette_oklab: Vec, palette_rgba: Vec>) -> Self { + assert_eq!(palette_oklab.len(), palette_rgba.len()); + assert!(!palette_oklab.is_empty(), "palette must not be empty"); + Self { + palette_oklab, + palette_rgba, + cache: HashMap::new(), } + } - let p_ok = rgba_to_oklab(*pixel); - let nearest_idx = palette_oklab - .iter() - .enumerate() - .min_by(|(_, a), (_, b)| { - oklab_distance_sq(p_ok, **a) - .partial_cmp(&oklab_distance_sq(p_ok, **b)) - .unwrap() - }) - .map(|(i, _)| i) - .unwrap(); - - let matched = palette_rgba[nearest_idx]; - *pixel = Rgba([matched[0], matched[1], matched[2], pixel[3]]); + /// Nearest palette entry for an RGB color: (palette index, RGBA). + pub fn nearest(&mut self, rgb: [u8; 3]) -> (usize, Rgba) { + let idx = match self.cache.get(&rgb) { + Some(&idx) => idx, + None => { + let p_ok = rgba_to_oklab(Rgba([rgb[0], rgb[1], rgb[2], 255])); + let idx = self + .palette_oklab + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + oklab_hyab(p_ok, **a).total_cmp(&oklab_hyab(p_ok, **b)) + }) + .map(|(i, _)| i) + .unwrap(); + self.cache.insert(rgb, idx); + idx + } + }; + (idx, self.palette_rgba[idx]) + } + + /// Snap every non-transparent pixel to the palette, preserving alpha. + /// Returns the number of unique colors encountered (cache size). + pub fn snap_image(&mut self, image: &mut RgbaImage) -> usize { + for pixel in image.pixels_mut() { + if pixel[3] == 0 { + continue; + } + let (_, matched) = self.nearest([pixel[0], pixel[1], pixel[2]]); + *pixel = Rgba([matched[0], matched[1], matched[2], pixel[3]]); + } + self.cache.len() } } @@ -35,15 +80,15 @@ pub fn snap_to_palette(image: &mut RgbaImage, palette_oklab: &[Oklab], palette_r pub fn palette_to_oklab(palette: &[[u8; 3]]) -> Vec { palette .iter() - .map(|&[r, g, b]| rgba_to_oklab(Rgba([r, g, b, 255]))) + .map(|c| rgba_to_oklab(Rgba([c[0], c[1], c[2], 255]))) .collect() } -/// Convert a palette of RGB colors to Rgba. +/// Convert a palette of RGB colors to RGBA (fully opaque). pub fn palette_to_rgba(palette: &[[u8; 3]]) -> Vec> { palette .iter() - .map(|&[r, g, b]| Rgba([r, g, b, 255])) + .map(|c| Rgba([c[0], c[1], c[2], 255])) .collect() } @@ -52,36 +97,66 @@ mod tests { use super::*; #[test] - fn test_snap_to_palette() { - let palette = [[255, 0, 0], [0, 255, 0], [0, 0, 255]]; - let oklab = palette_to_oklab(&palette); - let rgba = palette_to_rgba(&palette); - - let mut img = RgbaImage::new(3, 1); - img.put_pixel(0, 0, Rgba([250, 10, 5, 255])); // Near red - img.put_pixel(1, 0, Rgba([10, 240, 20, 255])); // Near green - img.put_pixel(2, 0, Rgba([5, 5, 250, 255])); // Near blue + fn test_snap_to_exact_palette_colors() { + let palette = [[255u8, 0, 0], [0, 0, 255]]; + let mut matcher = PaletteMatcher::new(&palette); - snap_to_palette(&mut img, &oklab, &rgba); + let mut img = RgbaImage::new(2, 1); + img.put_pixel(0, 0, Rgba([250, 10, 10, 255])); // near red + img.put_pixel(1, 0, Rgba([10, 10, 250, 128])); // near blue, semi-alpha + matcher.snap_image(&mut img); assert_eq!(*img.get_pixel(0, 0), Rgba([255, 0, 0, 255])); - assert_eq!(*img.get_pixel(1, 0), Rgba([0, 255, 0, 255])); - assert_eq!(*img.get_pixel(2, 0), Rgba([0, 0, 255, 255])); + // Color snapped, alpha preserved + assert_eq!(*img.get_pixel(1, 0), Rgba([0, 0, 255, 128])); } #[test] - fn test_snap_preserves_alpha() { - let palette = [[255, 0, 0]]; - let oklab = palette_to_oklab(&palette); - let rgba = palette_to_rgba(&palette); - - let mut img = RgbaImage::new(2, 1); - img.put_pixel(0, 0, Rgba([200, 50, 50, 128])); // Semi-transparent - img.put_pixel(1, 0, Rgba([200, 50, 50, 0])); // Fully transparent + fn test_transparent_pixels_untouched() { + let palette = [[255u8, 0, 0]]; + let mut matcher = PaletteMatcher::new(&palette); + let mut img = RgbaImage::from_pixel(1, 1, Rgba([12, 34, 56, 0])); + matcher.snap_image(&mut img); + assert_eq!(*img.get_pixel(0, 0), Rgba([12, 34, 56, 0])); + } - snap_to_palette(&mut img, &oklab, &rgba); + #[test] + fn test_hyab_matches_brute_force() { + // Parity check: the memoized matcher agrees with a direct hyAB scan. + let palette = [ + [0u8, 0, 0], + [255, 255, 255], + [200, 40, 40], + [40, 60, 200], + [30, 120, 60], + [230, 200, 60], + ]; + let mut matcher = PaletteMatcher::new(&palette); + let oklabs = palette_to_oklab(&palette); + + let mut seed = 7u32; + for _ in 0..1000 { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let rgb = [(seed >> 8) as u8, (seed >> 16) as u8, (seed >> 24) as u8]; + let p_ok = rgba_to_oklab(Rgba([rgb[0], rgb[1], rgb[2], 255])); + let expected = oklabs + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| oklab_hyab(p_ok, **a).total_cmp(&oklab_hyab(p_ok, **b))) + .map(|(i, _)| i) + .unwrap(); + assert_eq!(matcher.nearest(rgb).0, expected); + } + } - assert_eq!(img.get_pixel(0, 0)[3], 128); // Alpha preserved - assert_eq!(img.get_pixel(1, 0)[3], 0); // Transparent pixel untouched + #[test] + fn test_hyab_prefers_matching_lightness() { + // A dark saturated blue must snap to dark navy, not to a mid-gray + // whose Euclidean OKLAB distance is competitive via chroma. + let navy = [20u8, 24, 60]; + let gray = [95u8, 95, 105]; + let mut matcher = PaletteMatcher::new(&[navy, gray]); + let (idx, _) = matcher.nearest([25, 30, 90]); + assert_eq!(idx, 0, "dark blue should match navy on lightness"); } } diff --git a/src/color/palettes.rs b/src/color/palettes.rs index bcddc13..14bf192 100644 --- a/src/color/palettes.rs +++ b/src/color/palettes.rs @@ -207,14 +207,20 @@ pub const ENDESGA_64: PredefinedPalette = PredefinedPalette { }; /// Load a palette from a .hex file (one hex color per line, e.g., "FF0000"). -pub fn load_hex_file(path: &std::path::Path) -> Result, String> { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("Failed to read palette file {}: {}", path.display(), e))?; +pub fn load_hex_file(path: &std::path::Path) -> crate::error::Result> { + let content = std::fs::read_to_string(path).map_err(|e| { + crate::error::NormalizeError::Palette(format!( + "failed to read palette file {}: {}", + path.display(), + e + )) + })?; parse_hex_palette(&content) } /// Parse hex palette text (one color per line, "#RRGGBB" or "RRGGBB"). -pub fn parse_hex_palette(content: &str) -> Result, String> { +pub fn parse_hex_palette(content: &str) -> crate::error::Result> { + use crate::error::NormalizeError; let mut colors = Vec::new(); for (i, line) in content.lines().enumerate() { let line = line.trim(); @@ -223,18 +229,24 @@ pub fn parse_hex_palette(content: &str) -> Result, String> { } let hex = line.trim_start_matches('#'); if hex.len() != 6 { - return Err(format!("Line {}: invalid hex color '{}'", i + 1, line)); + return Err(NormalizeError::Palette(format!( + "line {}: invalid hex color '{}'", + i + 1, + line + ))); } let r = u8::from_str_radix(&hex[0..2], 16) - .map_err(|e| format!("Line {}: {}", i + 1, e))?; + .map_err(|e| NormalizeError::Palette(format!("line {}: {}", i + 1, e)))?; let g = u8::from_str_radix(&hex[2..4], 16) - .map_err(|e| format!("Line {}: {}", i + 1, e))?; + .map_err(|e| NormalizeError::Palette(format!("line {}: {}", i + 1, e)))?; let b = u8::from_str_radix(&hex[4..6], 16) - .map_err(|e| format!("Line {}: {}", i + 1, e))?; + .map_err(|e| NormalizeError::Palette(format!("line {}: {}", i + 1, e)))?; colors.push([r, g, b]); } if colors.is_empty() { - return Err("Palette file contains no colors".to_string()); + return Err(NormalizeError::Palette( + "palette file contains no colors".to_string(), + )); } Ok(colors) } diff --git a/src/config.rs b/src/config.rs index ee39dbd..0859132 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ -use anyhow::{Context, Result}; use serde::Deserialize; use std::path::Path; +use crate::error::{NormalizeError, Result}; + /// TOML configuration file structure. /// All fields are optional — CLI args override anything set here. #[derive(Debug, Deserialize, Default)] @@ -22,10 +23,12 @@ pub struct ConfigFile { #[derive(Debug, Deserialize, Default)] pub struct GridConfig { - pub size: Option, + pub size: Option, pub phase_x: Option, pub phase_y: Option, pub max_candidate: Option, + pub coarsen: Option, + pub min_confidence: Option, pub skip: Option, } @@ -49,6 +52,8 @@ pub struct BackgroundFileConfig { pub border_threshold: Option, pub color_tolerance: Option, pub flood_fill: Option, + pub chroma_keys: Option>, + pub chroma_tolerance: Option, } #[derive(Debug, Deserialize, Default)] @@ -64,36 +69,65 @@ pub struct SheetConfig { } /// Default config file name. -pub const CONFIG_FILE_NAME: &str = ".normalize-pixelart.toml"; +pub const CONFIG_FILE_NAME: &str = ".pixfix.toml"; + +/// Where the loaded config came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigSource { + /// Explicitly passed via --config. + Explicit(std::path::PathBuf), + /// Auto-discovered in the current directory. + Discovered(std::path::PathBuf), + /// No config file in play. + None, +} + +/// Load a config file. +/// +/// An explicitly passed path that does not exist is an error; cwd +/// auto-discovery is silent when absent. `no_config` skips discovery entirely. +pub fn load_config(explicit: Option<&Path>, no_config: bool) -> Result<(ConfigFile, ConfigSource)> { + if no_config { + return Ok((ConfigFile::default(), ConfigSource::None)); + } -/// Try to load a config file. Returns Default if not found. -pub fn load_config(path: Option<&Path>) -> Result { - let path = if let Some(p) = path { - p.to_path_buf() + let (path, source) = if let Some(p) = explicit { + if !p.exists() { + return Err(NormalizeError::Config(format!( + "config file not found: {}", + p.display() + ))); + } + (p.to_path_buf(), ConfigSource::Explicit(p.to_path_buf())) } else { - // Look in current directory let cwd_config = Path::new(CONFIG_FILE_NAME).to_path_buf(); if !cwd_config.exists() { - return Ok(ConfigFile::default()); + return Ok((ConfigFile::default(), ConfigSource::None)); } - cwd_config + (cwd_config.clone(), ConfigSource::Discovered(cwd_config)) }; - if !path.exists() { - return Ok(ConfigFile::default()); - } - - let content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read config file: {}", path.display()))?; - - let config: ConfigFile = toml::from_str(&content) - .with_context(|| format!("Failed to parse config file: {}", path.display()))?; - - Ok(config) + let content = std::fs::read_to_string(&path).map_err(|e| { + NormalizeError::Config(format!( + "failed to read config file {}: {}", + path.display(), + e + )) + })?; + + let config: ConfigFile = toml::from_str(&content).map_err(|e| { + NormalizeError::Config(format!( + "failed to parse config file {}: {}", + path.display(), + e + )) + })?; + + Ok((config, source)) } /// Parse a hex color string like "#FF0000" or "FF0000" into [r, g, b]. -pub fn parse_hex_color(s: &str) -> Result<[u8; 3], String> { +pub fn parse_hex_color(s: &str) -> std::result::Result<[u8; 3], String> { let s = s.trim().trim_start_matches('#'); if s.len() != 6 { return Err(format!("Invalid hex color '{}': must be 6 hex digits", s)); @@ -157,8 +191,16 @@ border_threshold = 0.5 } #[test] - fn test_load_missing_file() { - let config = load_config(Some(Path::new("/nonexistent/config.toml"))).unwrap(); + fn test_load_missing_explicit_file_errors() { + // An explicitly passed --config path that doesn't exist must error, + // not silently fall back to defaults. + assert!(load_config(Some(Path::new("/nonexistent/config.toml")), false).is_err()); + } + + #[test] + fn test_no_config_skips_discovery() { + let (config, source) = load_config(None, true).unwrap(); assert!(config.grid.size.is_none()); + assert_eq!(source, ConfigSource::None); } } diff --git a/src/error.rs b/src/error.rs index 7e0d043..51656bd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,30 +1,93 @@ +//! Typed errors for the pixfix library, plus the CLI exit-code +//! policy derived from them. + +use std::path::PathBuf; use thiserror::Error; +pub type Result = std::result::Result; + #[derive(Debug, Error)] pub enum NormalizeError { - #[error("Failed to load image: {0}")] - ImageLoad(#[from] image::ImageError), + #[error("I/O error on {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, - #[error("Grid detection failed: {0}")] - GridDetection(String), + #[error("failed to load image {path}: {source}")] + ImageLoad { + path: PathBuf, + #[source] + source: image::ImageError, + }, - #[error("Invalid grid size {0}: must be >= 2")] - InvalidGridSize(u32), + #[error("failed to encode image {path}: {source}")] + Encode { + path: PathBuf, + #[source] + source: image::ImageError, + }, + + #[error("invalid grid size {0}: must be >= 2")] + InvalidGridSize(f32), + + #[error( + "invalid grid phase ({x}, {y}): must be within the image and smaller than the grid size" + )] + InvalidPhase { x: u32, y: u32 }, + + #[error("grid detection failed: {0}")] + GridDetection(String), - #[error("Image too small ({width}x{height}) for grid size {grid_size}")] + #[error("image too small ({width}x{height}) for grid size {grid_size}")] ImageTooSmall { width: u32, height: u32, - grid_size: u32, + grid_size: f32, }, - #[error("No output pixels: image dimensions ({width}x{height}) with grid size {grid_size} produce 0 output pixels")] - NoOutputPixels { - width: u32, - height: u32, - grid_size: u32, - }, + #[error("palette error: {0}")] + Palette(String), + + #[error("Lospec error: {0}")] + Lospec(String), + + #[error("config error: {0}")] + Config(String), + + #[error("output already exists: {0} (use --overwrite to replace)")] + OutputExists(PathBuf), + + #[error("invalid input: {0}")] + InvalidInput(String), +} + +/// CLI exit-code policy (documented in the README): +/// 2 = usage or config error (clap itself also exits 2), 3 = input I/O, +/// 4 = processing/detection/encoding, 5 = output already exists, +/// 6 = partial batch failure (returned directly by main, not via an error). +pub mod exit { + pub const USAGE: u8 = 2; + pub const INPUT_IO: u8 = 3; + pub const PROCESSING: u8 = 4; + pub const OUTPUT_EXISTS: u8 = 5; + pub const PARTIAL_FAILURE: u8 = 6; +} - #[error("IO error: {0}")] - Io(#[from] std::io::Error), +/// Map an error to the exit code the CLI should terminate with. +pub fn exit_code(err: &NormalizeError) -> u8 { + use NormalizeError::*; + match err { + Io { .. } | ImageLoad { .. } => exit::INPUT_IO, + OutputExists(_) => exit::OUTPUT_EXISTS, + Config(_) | InvalidInput(_) => exit::USAGE, + Encode { .. } + | InvalidGridSize(_) + | InvalidPhase { .. } + | GridDetection(_) + | ImageTooSmall { .. } + | Palette(_) + | Lospec(_) => exit::PROCESSING, + } } diff --git a/src/image_util/flood.rs b/src/image_util/flood.rs new file mode 100644 index 0000000..870a101 --- /dev/null +++ b/src/image_util/flood.rs @@ -0,0 +1,86 @@ +//! Border-seeded flood fill, shared by background removal and sprite-sheet +//! auto-splitting. + +use std::collections::VecDeque; + +/// 4-connected flood fill seeded from every border pixel where `is_fill` +/// holds, expanding through `is_fill` pixels. Returns a row-major mask +/// (true = reached). +pub fn flood_fill_from_border( + width: u32, + height: u32, + is_fill: impl Fn(u32, u32) -> bool, +) -> Vec { + let mut mask = vec![false; (width * height) as usize]; + if width == 0 || height == 0 { + return mask; + } + + let mut queue = VecDeque::new(); + let seed = |x: u32, y: u32, mask: &mut Vec, queue: &mut VecDeque<(u32, u32)>| { + let idx = (y * width + x) as usize; + if !mask[idx] && is_fill(x, y) { + mask[idx] = true; + queue.push_back((x, y)); + } + }; + + for x in 0..width { + seed(x, 0, &mut mask, &mut queue); + seed(x, height - 1, &mut mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + seed(0, y, &mut mask, &mut queue); + seed(width - 1, y, &mut mask, &mut queue); + } + + while let Some((x, y)) = queue.pop_front() { + let neighbors = [ + (x.wrapping_sub(1), y), + (x + 1, y), + (x, y.wrapping_sub(1)), + (x, y + 1), + ]; + for (nx, ny) in neighbors { + if nx < width && ny < height { + let idx = (ny * width + nx) as usize; + if !mask[idx] && is_fill(nx, ny) { + mask[idx] = true; + queue.push_back((nx, ny)); + } + } + } + } + + mask +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fill_stops_at_walls() { + // 5x5: border fillable, center 3x3 walled except its middle, which + // must stay unreached (enclosed). + let wall = [ + (1u32, 1u32), + (2, 1), + (3, 1), + (1, 2), + (3, 2), + (1, 3), + (2, 3), + (3, 3), + ]; + let mask = flood_fill_from_border(5, 5, |x, y| !wall.contains(&(x, y))); + assert!(mask[0]); + assert!(!mask[(2 * 5 + 2) as usize], "enclosed center must not fill"); + } + + #[test] + fn test_fill_reaches_connected_interior() { + let mask = flood_fill_from_border(4, 4, |_, _| true); + assert!(mask.iter().all(|&b| b)); + } +} diff --git a/src/image_util/histogram.rs b/src/image_util/histogram.rs index f26d5f1..7d74b85 100644 --- a/src/image_util/histogram.rs +++ b/src/image_util/histogram.rs @@ -10,9 +10,15 @@ pub struct ColorHistogram { impl ColorHistogram { /// Build a histogram from all pixels in an image. pub fn from_image(image: &RgbaImage) -> Self { + Self::from_pixels(image.pixels().map(|p| p.0)) + } + + /// Build a histogram from an arbitrary pixel iterator (e.g. border + /// pixels only). + pub fn from_pixels>(pixels: I) -> Self { let mut counts = HashMap::new(); - for pixel in image.pixels() { - *counts.entry(pixel.0).or_insert(0) += 1; + for pixel in pixels { + *counts.entry(pixel).or_insert(0) += 1; } Self { counts } } @@ -50,11 +56,34 @@ impl ColorHistogram { .iter() .map(|(color, count)| (Rgba(*color), *count)) .collect(); - entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.sort_by_key(|&(_, n)| std::cmp::Reverse(n)); entries.truncate(n); entries } + /// All (color, count) entries in a deterministic order: count descending, + /// then RGBA bytes ascending. Every consumer that feeds clustering MUST + /// use this (HashMap iteration order would make results run-dependent). + pub fn sorted_entries(&self) -> Vec<([u8; 4], u32)> { + let mut entries: Vec<_> = self.counts.iter().map(|(&c, &n)| (c, n)).collect(); + entries.sort_by_key(|&(c, n)| (std::cmp::Reverse(n), c)); + entries + } + + /// Opaque entries merged by RGB (alpha stripped, alpha-0 pixels + /// excluded), in the same deterministic order. + pub fn opaque_rgb_counts(&self) -> Vec<([u8; 3], u32)> { + let mut merged: HashMap<[u8; 3], u32> = HashMap::new(); + for (&color, &count) in &self.counts { + if color[3] > 0 { + *merged.entry([color[0], color[1], color[2]]).or_insert(0) += count; + } + } + let mut entries: Vec<_> = merged.into_iter().collect(); + entries.sort_by_key(|&(c, n)| (std::cmp::Reverse(n), c)); + entries + } + /// Number of unique colors in the histogram. pub fn unique_colors(&self) -> usize { self.counts.len() diff --git a/src/image_util/io.rs b/src/image_util/io.rs index c97a7b0..f51ba7f 100644 --- a/src/image_util/io.rs +++ b/src/image_util/io.rs @@ -1,18 +1,188 @@ -use anyhow::{Context, Result}; -use image::RgbaImage; use std::path::Path; +use image::RgbaImage; + +use crate::error::{NormalizeError, Result}; + +/// Output encodings the pipeline can produce (all support RGBA). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { + #[default] + Png, + Webp, + Bmp, + Gif, +} + +impl OutputFormat { + pub fn extension(self) -> &'static str { + match self { + OutputFormat::Png => "png", + OutputFormat::Webp => "webp", + OutputFormat::Bmp => "bmp", + OutputFormat::Gif => "gif", + } + } + + pub fn image_format(self) -> image::ImageFormat { + match self { + OutputFormat::Png => image::ImageFormat::Png, + OutputFormat::Webp => image::ImageFormat::WebP, + OutputFormat::Bmp => image::ImageFormat::Bmp, + OutputFormat::Gif => image::ImageFormat::Gif, + } + } +} + /// Load an image from disk, converting to RGBA8. pub fn load_image(path: &Path) -> Result { - let img = image::open(path) - .with_context(|| format!("Failed to open image: {}", path.display()))?; + let img = image::open(path).map_err(|source| NormalizeError::ImageLoad { + path: path.to_path_buf(), + source, + })?; Ok(img.to_rgba8()) } -/// Save an RGBA image to disk. Format is inferred from the extension. -pub fn save_image(image: &RgbaImage, path: &Path) -> Result<()> { +/// Read raw bytes from stdin (the `-` input convention). +#[cfg(feature = "cli")] +pub fn read_stdin_bytes() -> Result> { + use std::io::Read; + + let mut bytes = Vec::new(); + std::io::stdin() + .read_to_end(&mut bytes) + .map_err(|source| NormalizeError::Io { + path: "".into(), + source, + })?; + Ok(bytes) +} + +/// Load an image from stdin (the `-` input convention). +#[cfg(feature = "cli")] +pub fn load_image_stdin() -> Result { + let bytes = read_stdin_bytes()?; + let img = image::load_from_memory(&bytes).map_err(|source| NormalizeError::ImageLoad { + path: "".into(), + source, + })?; + Ok(img.to_rgba8()) +} + +/// Encode an image to bytes in the given format. +pub fn encode_image(image: &RgbaImage, format: OutputFormat) -> Result> { + let mut bytes = std::io::Cursor::new(Vec::new()); image - .save(path) - .with_context(|| format!("Failed to save image: {}", path.display()))?; + .write_to(&mut bytes, format.image_format()) + .map_err(|source| NormalizeError::Encode { + path: format!("<{}>", format.extension()).into(), + source, + })?; + Ok(bytes.into_inner()) +} + +/// Save an image atomically: encode to a temp file in the destination +/// directory, then rename into place. An encode failure never leaves a +/// partial or zero-byte file behind. +#[cfg(feature = "cli")] +pub fn save_image_atomic(image: &RgbaImage, path: &Path, format: OutputFormat) -> Result<()> { + let bytes = encode_image(image, format).map_err(|e| match e { + NormalizeError::Encode { source, .. } => NormalizeError::Encode { + path: path.to_path_buf(), + source, + }, + other => other, + })?; + save_bytes_atomic(&bytes, path) +} + +/// Write already-encoded bytes atomically (temp file + rename), same +/// no-partial-file guarantee as [`save_image_atomic`]. Used for animated +/// GIF output, where encoding happens up front. +#[cfg(feature = "cli")] +pub fn save_bytes_atomic(bytes: &[u8], path: &Path) -> Result<()> { + use std::io::Write; + + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()); + let mut tmp = + tempfile::NamedTempFile::new_in(dir.unwrap_or(Path::new("."))).map_err(|source| { + NormalizeError::Io { + path: path.to_path_buf(), + source, + } + })?; + tmp.write_all(bytes).map_err(|source| NormalizeError::Io { + path: path.to_path_buf(), + source, + })?; + tmp.persist(path).map_err(|e| NormalizeError::Io { + path: path.to_path_buf(), + source: e.error, + })?; Ok(()) } + +/// Save an image to disk (format from the extension). Prefer +/// [`save_image_atomic`] for pipeline outputs. +pub fn save_image(image: &RgbaImage, path: &Path) -> Result<()> { + image.save(path).map_err(|source| NormalizeError::Encode { + path: path.to_path_buf(), + source, + }) +} + +/// Write an encoded image to stdout (the `-` output convention). +#[cfg(feature = "cli")] +pub fn write_image_stdout(image: &RgbaImage, format: OutputFormat) -> Result<()> { + let bytes = encode_image(image, format)?; + write_bytes_stdout(&bytes) +} + +/// Write already-encoded bytes to stdout (the `-` output convention). +#[cfg(feature = "cli")] +pub fn write_bytes_stdout(bytes: &[u8]) -> Result<()> { + use std::io::Write; + + std::io::stdout() + .write_all(bytes) + .map_err(|source| NormalizeError::Io { + path: "".into(), + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + #[cfg(feature = "cli")] + #[test] + fn test_atomic_save_leaves_no_partial_on_success() { + let img = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + let dir = std::env::temp_dir().join("np_io_test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("out.png"); + save_image_atomic(&img, &path, OutputFormat::Png).unwrap(); + let reloaded = load_image(&path).unwrap(); + assert_eq!(reloaded, img); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_encode_roundtrip_all_formats() { + let img = RgbaImage::from_pixel(3, 2, Rgba([10, 200, 30, 255])); + for format in [ + OutputFormat::Png, + OutputFormat::Webp, + OutputFormat::Bmp, + OutputFormat::Gif, + ] { + let bytes = encode_image(&img, format).unwrap(); + let back = image::load_from_memory(&bytes).unwrap().to_rgba8(); + assert_eq!(back, img, "roundtrip failed for {:?}", format); + } + } +} diff --git a/src/image_util/mod.rs b/src/image_util/mod.rs index 2ebded9..cb6139a 100644 --- a/src/image_util/mod.rs +++ b/src/image_util/mod.rs @@ -1,3 +1,4 @@ +pub mod flood; pub mod histogram; pub mod io; pub mod neighbors; diff --git a/src/image_util/neighbors.rs b/src/image_util/neighbors.rs index 3a06478..9639213 100644 --- a/src/image_util/neighbors.rs +++ b/src/image_util/neighbors.rs @@ -1,7 +1,7 @@ use image::{Rgba, RgbaImage}; /// The 8 directions for 8-connected neighborhood. -const OFFSETS: [(i32, i32); 8] = [ +pub const OFFSETS: [(i32, i32); 8] = [ (-1, -1), (0, -1), (1, -1), @@ -36,7 +36,7 @@ pub fn neighbor_colors(image: &RgbaImage, x: u32, y: u32) -> Vec<(Rgba, u32) } } - counts.sort_by(|a, b| b.1.cmp(&a.1)); + counts.sort_by_key(|&(_, n)| std::cmp::Reverse(n)); counts.into_iter().map(|(c, n)| (Rgba(c), n)).collect() } diff --git a/src/lib.rs b/src/lib.rs index aefdfd0..a60abac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,17 @@ +pub mod anim; +#[cfg(feature = "cli")] pub mod batch; +#[cfg(feature = "cli")] pub mod cli; pub mod color; pub mod config; pub mod error; pub mod image_util; +pub(crate) mod parallel; +#[cfg(feature = "cli")] +pub mod paths; pub mod pipeline; +pub mod report; pub mod spritesheet; #[cfg(feature = "tui")] diff --git a/src/main.rs b/src/main.rs index 290b7bc..dbc204f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,25 @@ -use anyhow::{bail, Context, Result}; +use std::process::ExitCode; +use std::time::Instant; + use clap::Parser; use tracing::info; -use normalize_pixelart::cli::{Cli, Commands, PaletteAction, PipelineFlags, ProcessArgs}; -use normalize_pixelart::color::palettes; -use normalize_pixelart::config::{load_config, parse_hex_color, ConfigFile}; -use normalize_pixelart::image_util::io::{load_image, save_image}; -use normalize_pixelart::pipeline::{ - AaRemovalConfig, BackgroundConfig, DownscaleMode, GridDetectConfig, PipelineConfig, - QuantizeConfig, run_pipeline, +use pixfix::cli::{Cli, Commands, PaletteAction, PipelineFlags, ProcessArgs}; +use pixfix::color::palettes; +use pixfix::config::{load_config, ConfigFile, ConfigSource}; +use pixfix::error::{exit, exit_code, NormalizeError, Result}; +use pixfix::image_util::io::{ + load_image, read_stdin_bytes, save_bytes_atomic, save_image_atomic, write_bytes_stdout, + write_image_stdout, OutputFormat, +}; +use pixfix::paths::check_writable; +use pixfix::pipeline::{resolve_pipeline_config, run_pipeline, PipelineConfig, PipelineState}; +use pixfix::report::{ + hex, AnalyzeReport, BatchFileReport, BatchReport, BatchSummaryReport, CoarsenCandidate, + FileStatus, GridReport, ProcessReport, Reporter, SheetReport, }; -fn main() -> Result<()> { +fn main() -> ExitCode { let cli = Cli::parse(); // Set up tracing based on verbosity @@ -33,265 +41,766 @@ fn main() -> Result<()> { .with_writer(std::io::stderr) .init(); + let reporter = Reporter::new(cli.json, cli.quiet); + match dispatch(cli, &reporter) { + Ok(code) => code, + Err(e) => { + reporter.error(&e); + ExitCode::from(exit_code(&e)) + } + } +} + +fn dispatch(cli: Cli, reporter: &Reporter) -> Result { // Load config file - let config_file = load_config(cli.config.as_deref()) - .context("Failed to load config file")?; + let (config_file, config_source) = load_config(cli.config.as_deref(), cli.no_config)?; + match &config_source { + ConfigSource::Explicit(p) => info!(path = %p.display(), "Using config file"), + ConfigSource::Discovered(p) => info!(path = %p.display(), "Using discovered config file"), + ConfigSource::None => {} + } match cli.command { - Commands::Process(args) => run_process(args, &config_file), - Commands::Batch(args) => run_batch_command(args, &config_file), - Commands::Sheet(args) => run_sheet_command(args, &config_file), + Commands::Process(args) => run_process(args, &config_file, reporter), + Commands::Analyze(args) => run_analyze(args, &config_file, reporter), + Commands::Batch(args) => run_batch_command(args, &config_file, reporter), + Commands::Sheet(args) => run_sheet_command(args, &config_file, reporter), Commands::Palette(args) => match args.action { - PaletteAction::List => run_palette_list(), - PaletteAction::Extract(extract_args) => run_palette_extract(extract_args), + PaletteAction::List => run_palette_list(reporter), + PaletteAction::Extract(extract_args) => run_palette_extract(extract_args, reporter), #[cfg(feature = "lospec")] - PaletteAction::Fetch(fetch_args) => run_palette_fetch(fetch_args), + PaletteAction::Fetch(fetch_args) => run_palette_fetch(fetch_args, reporter), }, #[cfg(feature = "tui")] Commands::Tui(args) => { - let (config, _) = build_pipeline_config(&args.pipeline, &config_file)?; - normalize_pixelart::tui::run_tui(args.input, config) + let (config, _, _) = build_config(&args.pipeline, &config_file)?; + pixfix::tui::run_tui(args.input, config) + .map_err(|e| NormalizeError::InvalidInput(format!("TUI error: {}", e)))?; + Ok(ExitCode::SUCCESS) } } } -/// Build a PipelineConfig from shared PipelineFlags + config file. -fn build_pipeline_config( +/// Assemble the pipeline config (CLI over config file over defaults). +/// Also returns the palette label for reporting and whether the grid came +/// from a manual override. +fn build_config( flags: &PipelineFlags, config_file: &ConfigFile, -) -> Result<(PipelineConfig, Option>)> { - // Load custom palette: --palette-file takes priority over --lospec - let custom_palette = if let Some(ref path) = flags.palette_file { - let colors = palettes::load_hex_file(path) - .map_err(|e| anyhow::anyhow!(e))?; - eprintln!("Loaded {} colors from {}", colors.len(), path.display()); - Some(colors) +) -> Result<(PipelineConfig, Option, bool)> { + let cli_options = flags.to_options()?; + let file_options = config_file.to_options()?; + let palette_label = cli_options + .palette + .as_ref() + .or(file_options.palette.as_ref()) + .map(|p| p.label()); + let grid_overridden = cli_options.grid_size.or(file_options.grid_size).is_some(); + let config = resolve_pipeline_config(cli_options, file_options)?; + Ok((config, palette_label, grid_overridden)) +} + +/// Shared human-facing pipeline result lines. +fn report_pipeline_human(reporter: &Reporter, report: &ProcessReport) { + if let Some(ref grid) = report.grid { + let pitch = |p: f32| { + if p.fract() == 0.0 { + format!("{}", p as u32) + } else { + format!("{:.3}", p) + } + }; + let confidence = grid + .confidence + .map(|c| format!("{:.0}%", c * 100.0)) + .unwrap_or_else(|| "N/A".to_string()); + let logical = report + .logical_size + .map(|[w, h]| format!("{}x{}", w, h)) + .unwrap_or_else(|| "?".to_string()); + reporter.info(format_args!( + "Grid: {}x{} pixels, phase: ({}, {}), confidence: {}, logical: {}", + pitch(grid.pitch_x), + pitch(grid.pitch_y), + pitch(grid.phase[0]), + pitch(grid.phase[1]), + confidence, + logical + )); + } else if let Some(ref guess) = report.grid_best_guess { + reporter.info(format_args!( + "Grid detection low confidence (best guess: {:.3}px, {:.0}%) — pass --grid-size to force", + guess.pitch_x, + guess.confidence.unwrap_or(0.0) * 100.0 + )); + } + + for w in &report.warnings { + if !w.starts_with("grid detection confidence") { + reporter.info(w); + } + } + reporter.info(format_args!( + "Output: {}x{}", + report.output_size[0], report.output_size[1] + )); +} + +fn resolved_overwrite(explicit: Option, config_file: &ConfigFile) -> bool { + explicit.or(config_file.output.overwrite).unwrap_or(false) +} + +fn run_process( + args: ProcessArgs, + config_file: &ConfigFile, + reporter: &Reporter, +) -> Result { + let stdin_input = args.input.as_os_str() == "-"; + let stdout_output = args + .output + .as_ref() + .map(|o| o.as_os_str() == "-") + .unwrap_or(false); + if stdout_output && reporter.is_json() { + return Err(NormalizeError::InvalidInput( + "--json needs stdout for the report; it cannot be combined with '-' output".to_string(), + )); + } + if stdin_input && args.output.is_none() { + return Err(NormalizeError::InvalidInput( + "stdin input needs an explicit output path (or '-')".to_string(), + )); + } + + let overwrite = resolved_overwrite(args.overwrite_opt(), config_file); + + // Build the config before touching the image so flag/config errors + // surface immediately. + let (mut config, palette_label, grid_overridden) = build_config(&args.pipeline, config_file)?; + config.output_width = args.target_width; + config.output_height = args.target_height; + + // Animation probe: raw bytes are only read for stdin (needed anyway) + // and .gif paths; every other input keeps the plain load path. A GIF + // with more than one frame takes the animated pipeline. + let raw_bytes: Option> = if stdin_input { + Some(read_stdin_bytes()?) + } else if has_gif_extension(&args.input) { + Some( + std::fs::read(&args.input).map_err(|source| NormalizeError::Io { + path: args.input.clone(), + source, + })?, + ) } else { None }; - - #[cfg(feature = "lospec")] - let custom_palette = if custom_palette.is_none() { - if let Some(ref slug) = flags.lospec { - let pal = normalize_pixelart::color::lospec::fetch_lospec_palette(slug) - .map_err(|e| anyhow::anyhow!(e))?; - eprintln!("Lospec: {} ({} colors)", pal.name, pal.colors.len()); - Some(pal.colors) - } else { - None - } + let input_label: std::path::PathBuf = if stdin_input { + "".into() } else { - custom_palette + args.input.clone() }; + if let Some(bytes) = raw_bytes.as_deref() { + if pixfix::anim::is_gif(bytes) { + let anim = pixfix::anim::decode_gif(bytes).map_err(|e| match e { + NormalizeError::ImageLoad { source, .. } => NormalizeError::ImageLoad { + path: input_label.clone(), + source, + }, + other => other, + })?; + if anim.frames.len() > 1 { + return run_process_animated( + &args, + anim, + &config, + palette_label, + grid_overridden, + overwrite, + stdout_output, + reporter, + ); + } + } + } - // Parse explicit background color - let bg_color = if let Some(ref hex) = flags.bg_color { - Some(parse_hex_color(hex).map_err(|e| anyhow::anyhow!(e))?) - } else if let Some(ref hex) = config_file.background.color { - Some(parse_hex_color(hex).map_err(|e| anyhow::anyhow!(e))?) - } else { - None + let output_path = args.output_path(); + if !stdout_output { + check_writable(&output_path, overwrite)?; + } + + info!(path = %args.input.display(), "Loading image"); + let image = match raw_bytes { + Some(bytes) => image::load_from_memory(&bytes) + .map_err(|source| NormalizeError::ImageLoad { + path: input_label, + source, + })? + .to_rgba8(), + None => load_image(&args.input)?, }; + reporter.info(format_args!( + "Loaded {}x{} image: {}", + image.width(), + image.height(), + args.input.display() + )); - // Parse downscale mode - let downscale_mode: DownscaleMode = flags - .downscale_mode - .parse() - .map_err(|e: String| anyhow::anyhow!(e))?; - - // AA removal: off by default, enabled only if --aa-threshold is specified - let (aa_threshold, aa_skip) = match flags.aa_threshold { - Some(t) => (t, false), - None => match config_file.aa.threshold { - Some(t) => (t, config_file.aa.skip.unwrap_or(false)), - None => (0.5, true), - }, + let overlay_source = args.debug_overlay.as_ref().map(|_| image.clone()); + + let started = Instant::now(); + let state = run_pipeline(image, &config)?; + let duration_ms = started.elapsed().as_millis() as u64; + + if let (Some(path), Some(source)) = (&args.debug_overlay, overlay_source) { + match (&state.grid, &state.diagnostics.block_vote_shares) { + (Some(grid), Some(shares)) => { + check_writable(path, overwrite)?; + let overlay = pixfix::pipeline::render_share_overlay(&source, grid, shares); + save_image_atomic(&overlay, path, OutputFormat::Png)?; + reporter.info(format_args!("Debug overlay: {}", path.display())); + } + _ => reporter.info("Debug overlay skipped: no grid was applied"), + } + } + + if stdout_output { + write_image_stdout(&state.image, args.output_format)?; + } else { + save_image_atomic(&state.image, &output_path, args.output_format)?; + } + + let report = ProcessReport::from_state( + &state, + args.input.display().to_string(), + output_path.display().to_string(), + palette_label, + grid_overridden, + duration_ms, + ); + report_pipeline_human(reporter, &report); + reporter.info(format_args!("Saved: {}", output_path.display())); + reporter.emit(&report); + + Ok(ExitCode::SUCCESS) +} + +fn has_gif_extension(path: &std::path::Path) -> bool { + path.extension() + .map(|e| e.eq_ignore_ascii_case("gif")) + .unwrap_or(false) +} + +/// The animated `process` path: one grid, one background color, and one +/// palette shared across all frames; per-frame timing preserved; output is +/// always an animated GIF. +#[allow(clippy::too_many_arguments)] +fn run_process_animated( + args: &ProcessArgs, + anim: pixfix::anim::AnimatedImage, + config: &PipelineConfig, + palette_label: Option, + grid_overridden: bool, + overwrite: bool, + stdout_output: bool, + reporter: &Reporter, +) -> Result { + // Animated output is always a GIF: the png default is upgraded, an + // explicit gif is honored, anything else is an error. + match args.output_format { + OutputFormat::Png | OutputFormat::Gif => {} + other => { + return Err(NormalizeError::InvalidInput(format!( + "input is an animated GIF, so the output is always an animated GIF; \ + --output-format {} is not supported here (drop the flag or pass gif)", + other.extension() + ))); + } + } + + let output_path = match args.output { + Some(ref out) => out.clone(), + None => pixfix::paths::derive_output_path( + &args.input, + None, + &pixfix::paths::OutputNaming { + suffix: "_normalized", + extension: "gif", + }, + ), }; + if !stdout_output { + check_writable(&output_path, overwrite)?; + } - let config = PipelineConfig { - grid: GridDetectConfig { - override_size: flags.grid_size.or(config_file.grid.size), - override_phase: flags.grid_phase, - max_candidate: config_file.grid.max_candidate.unwrap_or(flags.max_grid_candidate), - skip: flags.no_grid_detect || config_file.grid.skip.unwrap_or(false), - }, - aa: AaRemovalConfig { - threshold: aa_threshold, - skip: aa_skip, - }, - quantize: QuantizeConfig { - num_colors: flags.colors.or(config_file.quantize.colors), - palette_name: flags.palette.clone().or(config_file.quantize.palette.clone()), - custom_palette, - skip: flags.no_quantize || config_file.quantize.skip.unwrap_or(false), - ..Default::default() - }, - background: BackgroundConfig { - enabled: flags.remove_bg || config_file.background.enabled.unwrap_or(false), - bg_color, - border_threshold: flags - .bg_threshold - .or(config_file.background.border_threshold) - .unwrap_or(0.4), - color_tolerance: flags - .bg_tolerance - .or(config_file.background.color_tolerance) - .unwrap_or(0.05), - flood_fill: !flags.no_flood_fill - && config_file.background.flood_fill.unwrap_or(true), - }, - downscale_mode, - output_width: None, - output_height: None, + let frame_count = anim.frames.len() as u32; + reporter.info(format_args!( + "Loaded {}x{} animated GIF ({} frames): {}", + anim.frames[0].width(), + anim.frames[0].height(), + frame_count, + args.input.display() + )); + + let overlay_source = args.debug_overlay.as_ref().map(|_| anim.frames[0].clone()); + + let started = Instant::now(); + let state = pixfix::anim::run_pipeline_animated(anim, config)?; + let duration_ms = started.elapsed().as_millis() as u64; + + // Debug overlay renders from the first frame. + if let (Some(path), Some(source)) = (&args.debug_overlay, overlay_source) { + match (&state.grid, &state.diagnostics.block_vote_shares) { + (Some(grid), Some(shares)) => { + check_writable(path, overwrite)?; + let overlay = pixfix::pipeline::render_share_overlay(&source, grid, shares); + save_image_atomic(&overlay, path, OutputFormat::Png)?; + reporter.info(format_args!("Debug overlay: {}", path.display())); + } + _ => reporter.info("Debug overlay skipped: no grid was applied"), + } + } + + let bytes = pixfix::anim::encode_gif(&state.frames, &state.delays)?; + if stdout_output { + write_bytes_stdout(&bytes)?; + } else { + save_bytes_atomic(&bytes, &output_path)?; + } + + // Reuse the still-image report from a first-frame view of the result; + // only the frame count is animation-specific. + let first_frame = state.frames.first().expect("animated state has frames"); + let report_state = PipelineState { + image: first_frame.clone(), + original_width: state.original_width, + original_height: state.original_height, + grid: state.grid, + bg_mask: None, + dither_pairs: Vec::new(), + diagnostics: state.diagnostics.clone(), }; + let mut report = ProcessReport::from_state( + &report_state, + args.input.display().to_string(), + output_path.display().to_string(), + palette_label, + grid_overridden, + duration_ms, + ); + report.frames = frame_count; + report_pipeline_human(reporter, &report); + reporter.info(format_args!("Saved: {}", output_path.display())); + reporter.emit(&report); - Ok((config, None)) + Ok(ExitCode::SUCCESS) } -fn run_process(args: ProcessArgs, config_file: &ConfigFile) -> Result<()> { - let output_path = args.output_path(); +fn run_analyze( + args: pixfix::cli::AnalyzeArgs, + config_file: &ConfigFile, + reporter: &Reporter, +) -> Result { + use pixfix::image_util::histogram::ColorHistogram; + use pixfix::pipeline::{background, grid_detect, PipelineState}; - // Check if output already exists - let overwrite = args.overwrite || config_file.output.overwrite.unwrap_or(false); - if output_path.exists() && !overwrite { - bail!( - "Output file already exists: {}. Use --overwrite to replace.", - output_path.display() - ); - } + let (config, _, grid_overridden) = build_config(&args.pipeline, config_file)?; - // Load the input image - info!(path = %args.input.display(), "Loading image"); - let image = load_image(&args.input)?; + // Animated GIFs are analyzed on their first frame; the frame count is + // reported alongside. + let mut frames = 1u32; + let image = if has_gif_extension(&args.input) { + let bytes = std::fs::read(&args.input).map_err(|source| NormalizeError::Io { + path: args.input.clone(), + source, + })?; + if pixfix::anim::is_gif(&bytes) { + let anim = pixfix::anim::decode_gif(&bytes).map_err(|e| match e { + NormalizeError::ImageLoad { source, .. } => NormalizeError::ImageLoad { + path: args.input.clone(), + source, + }, + other => other, + })?; + frames = anim.frames.len() as u32; + anim.frames + .into_iter() + .next() + .expect("decoder rejects empty GIFs") + } else { + load_image(&args.input)? + } + } else { + load_image(&args.input)? + }; let (w, h) = (image.width(), image.height()); - eprintln!("Loaded {}x{} image: {}", w, h, args.input.display()); - let (mut config, _) = build_pipeline_config(&args.pipeline, config_file)?; - config.output_width = args.target_width; - config.output_height = args.target_height; + // Grid detection on a scratch state — nothing is written anywhere. + let mut state = PipelineState::new(image); + if !config.grid.skip { + grid_detect::detect_grid(&mut state, &config.grid)?; + } else if let Some(grid) = grid_detect::override_grid(&config.grid) { + grid.validate(w, h)?; + state.grid = Some(grid); + } - // Run the pipeline - let state = run_pipeline(image, &config).context("Pipeline execution failed")?; + // Background detection always runs for analyze (it's inspection). + let bg = background::resolve_background(&state.image, &config.background); + let unique_colors = ColorHistogram::from_image(&state.image).unique_colors() as u32; - // Report results - if let Some(grid_size) = state.grid_size { - let phase = state.grid_phase.unwrap_or((0, 0)); - let confidence = state - .diagnostics - .grid_confidence - .map(|c| format!("{:.0}%", c * 100.0)) - .unwrap_or_else(|| "N/A".to_string()); - let logical_w = state.original_width / grid_size; - let logical_h = state.original_height / grid_size; - eprintln!( - "Grid: {}x{} pixels, phase: ({}, {}), confidence: {}, logical: {}x{}", - grid_size, grid_size, phase.0, phase.1, confidence, logical_w, logical_h - ); + let sheet_sprites = if args.sheet { + let auto_config = pixfix::spritesheet::AutoSplitConfig { + bg_color: config.background.bg_color, + tolerance: config.background.color_tolerance, + ..Default::default() + }; + pixfix::spritesheet::auto_split_sheet(&state.image, &auto_config) + .ok() + .map(|(tiles, _, _)| tiles.len() as u32) + } else { + None + }; + + // Artistic-resolution probe: the detected pitch is the render quantum; + // snap at 1x/2x/4x and compare contested-block rates. A coarser factor + // whose rate stays near the baseline reads cleanly as the art's true + // resolution. + let mut coarsen_candidates = Vec::new(); + let mut suggested_coarsen = None; + if let Some(base_grid) = state.grid { + use pixfix::pipeline::{downscale, DownscaleConfig, Grid}; + let mut baseline_rate = None; + for factor in [1u32, 2, 4] { + let f = factor as f32; + let grid = Grid::new( + base_grid.pitch_x * f, + base_grid.pitch_y * f, + base_grid.phase_x, + base_grid.phase_y, + ); + if grid.validate(w, h).is_err() { + break; + } + let mut probe = pixfix::pipeline::PipelineState::new(state.image.clone()); + probe.grid = Some(grid); + if downscale::majority_vote_downscale(&mut probe, &DownscaleConfig::default()).is_err() + { + break; + } + let rate = probe + .diagnostics + .block_vote_shares + .as_ref() + .map(|m| m.low_confidence_blocks().len() as f32 / m.shares.len().max(1) as f32) + .unwrap_or(0.0); + let (lw, lh) = grid.logical_size(w, h); + coarsen_candidates.push(CoarsenCandidate { + factor, + pitch_x: grid.pitch_x, + logical_size: [lw, lh], + contested_rate: rate, + }); + let base = *baseline_rate.get_or_insert(rate); + if factor > 1 && rate - base <= 0.15 && rate <= 0.5 { + suggested_coarsen = Some(factor); + } + } } - let (ow, oh) = (state.image.width(), state.image.height()); - eprintln!("Output: {}x{}", ow, oh); + let d = &state.diagnostics; + let mut warnings = Vec::new(); + let grid = state + .grid + .map(|g| GridReport::from_grid(&g, d.grid_confidence, grid_overridden)); + let grid_best_guess = if state.grid.is_none() { + d.grid_best_guess.map(|g| { + warnings.push( + "grid detection confidence below floor (pass --grid-size to force)".to_string(), + ); + GridReport::from_grid(&g, d.grid_confidence, false) + }) + } else { + None + }; - // Save - save_image(&state.image, &output_path)?; - eprintln!("Saved: {}", output_path.display()); + let report = AnalyzeReport { + input: args.input.display().to_string(), + size: [w, h], + frames, + logical_size: state.grid.map(|g| { + let (lw, lh) = g.logical_size(w, h); + [lw, lh] + }), + grid, + grid_best_guess, + unique_colors, + detected_bg: bg.map(|b| hex([b.color[0], b.color[1], b.color[2]])), + bg_border_coverage: bg.map(|b| b.coverage), + sheet_sprites, + coarsen_candidates, + suggested_coarsen, + warnings, + }; - Ok(()) + // Human output + reporter.info(format_args!( + "Image: {}x{} ({})", + w, + h, + args.input.display() + )); + if report.frames > 1 { + reporter.info(format_args!( + "Frames: {} (animated GIF; first frame analyzed)", + report.frames + )); + } + if let Some(ref g) = report.grid { + reporter.info(format_args!( + "Grid: {:.3}x{:.3} px, phase ({:.1}, {:.1}), confidence {:.0}%", + g.pitch_x, + g.pitch_y, + g.phase[0], + g.phase[1], + g.confidence.unwrap_or(0.0) * 100.0 + )); + if let Some([lw, lh]) = report.logical_size { + reporter.info(format_args!("Logical size: {}x{}", lw, lh)); + } + } else if let Some(ref g) = report.grid_best_guess { + reporter.info(format_args!( + "Grid: none accepted (best guess {:.3}px at {:.0}% confidence)", + g.pitch_x, + g.confidence.unwrap_or(0.0) * 100.0 + )); + } else { + reporter.info("Grid: none detected"); + } + reporter.info(format_args!("Unique colors: {}", report.unique_colors)); + match (&report.detected_bg, report.bg_border_coverage) { + (Some(bg), Some(cov)) => reporter.info(format_args!( + "Background: {} ({:.0}% of border)", + bg, + cov * 100.0 + )), + _ => reporter.info("Background: none detected"), + } + if let Some(n) = report.sheet_sprites { + reporter.info(format_args!("Sheet auto-split: {} sprites", n)); + } + if let Some(f) = report.suggested_coarsen { + let rates: Vec = report + .coarsen_candidates + .iter() + .map(|c| format!("{}x: {:.0}%", c.factor, c.contested_rate * 100.0)) + .collect(); + reporter.info(format_args!( + "Detected pitch is the render quantum; content also reads cleanly coarser (contested blocks at {}). Consider --coarsen {}", + rates.join(", "), + f + )); + } + + reporter.emit(&report); + Ok(ExitCode::SUCCESS) } fn run_batch_command( - args: normalize_pixelart::cli::BatchArgs, + args: pixfix::cli::BatchArgs, config_file: &ConfigFile, -) -> Result<()> { - let (config, _) = build_pipeline_config(&args.pipeline, config_file)?; + reporter: &Reporter, +) -> Result { + use pixfix::batch::{BatchEvent, BatchOptions}; + use pixfix::report::ProgressEvent; + + let (config, _, _) = build_config(&args.pipeline, config_file)?; // Resolve input files via glob - let inputs = normalize_pixelart::batch::resolve_inputs(&args.input)?; - if inputs.is_empty() { - bail!("No input files matched pattern: {}", args.input); + let inputs = pixfix::batch::resolve_inputs(&args.input)?; + let total = inputs.files.len(); + if total == 0 { + return Err(NormalizeError::InvalidInput(format!( + "no input files matched pattern: {}", + args.input + ))); } - eprintln!("Found {} input files", inputs.len()); + reporter.info(format_args!("Found {} input files", total)); - // Create output directory - std::fs::create_dir_all(&args.output) - .with_context(|| format!("Failed to create output directory: {}", args.output.display()))?; + std::fs::create_dir_all(&args.output).map_err(|source| NormalizeError::Io { + path: args.output.clone(), + source, + })?; - let overwrite = args.overwrite || config_file.output.overwrite.unwrap_or(false); - let result = normalize_pixelart::batch::run_batch(&inputs, &args.output, &config, overwrite)?; + let opts = BatchOptions { + overwrite: resolved_overwrite(args.overwrite_opt(), config_file), + suffix: args + .suffix + .clone() + .unwrap_or_else(|| "_normalized".to_string()), + preserve_dirs: args.preserve_dirs, + format: args.output_format, + }; - eprintln!( - "\nBatch complete: {} succeeded, {} failed", - result.succeeded, result.failed.len() - ); - for (path, err) in &result.failed { - eprintln!(" FAILED {}: {}", path.display(), err); - } + // Progress: indicatif bar for humans (hidden when quiet/json), NDJSON + // events for agents. + let bar = if reporter.is_json() || reporter.is_quiet() { + indicatif::ProgressBar::hidden() + } else { + let bar = indicatif::ProgressBar::new(total as u64); + if let Ok(style) = indicatif::ProgressStyle::default_bar() + .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}") + { + bar.set_style(style.progress_chars("##-")); + } + bar + }; + + let progress = |event: BatchEvent| match &event { + BatchEvent::Started { .. } => { + reporter.progress(&ProgressEvent::BatchStarted { total }); + } + BatchEvent::FileDone { + index, + input, + output, + } => { + bar.inc(1); + bar.set_message( + input + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ); + reporter.progress(&ProgressEvent::FileDone { + index: *index, + total, + input: input.display().to_string(), + output: Some(output.display().to_string()), + status: FileStatus::Ok, + error: None, + }); + } + BatchEvent::FileSkipped { + index, + input, + reason, + } => { + bar.inc(1); + reporter.progress(&ProgressEvent::FileDone { + index: *index, + total, + input: input.display().to_string(), + output: None, + status: FileStatus::Skipped, + error: Some(reason.clone()), + }); + } + BatchEvent::FileFailed { + index, + input, + error, + } => { + bar.inc(1); + reporter.progress(&ProgressEvent::FileDone { + index: *index, + total, + input: input.display().to_string(), + output: None, + status: FileStatus::Failed, + error: Some(error.clone()), + }); + } + BatchEvent::Finished { .. } => bar.finish_and_clear(), + }; - if !result.failed.is_empty() { - bail!("{} images failed to process", result.failed.len()); + let result = pixfix::batch::run_batch(&inputs, &args.output, &config, &opts, progress)?; + + reporter.info(format_args!( + "Batch complete: {} succeeded, {} failed, {} skipped", + result.succeeded(), + result.failed(), + result.skipped() + )); + for outcome in &result.files { + if outcome.status == FileStatus::Failed { + reporter.info(format_args!( + " FAILED {}: {}", + outcome.input.display(), + outcome.error.as_deref().unwrap_or("unknown error") + )); + } } - Ok(()) + let report = BatchReport { + summary: BatchSummaryReport { + total, + succeeded: result.succeeded(), + failed: result.failed(), + skipped: result.skipped(), + }, + files: result + .files + .iter() + .map(|o| BatchFileReport { + input: o.input.display().to_string(), + output: o.output.as_ref().map(|p| p.display().to_string()), + status: o.status, + error: o.error.clone(), + }) + .collect(), + }; + reporter.emit(&report); + + if result.failed() > 0 { + return Ok(ExitCode::from(exit::PARTIAL_FAILURE)); + } + Ok(ExitCode::SUCCESS) } fn run_sheet_command( - args: normalize_pixelart::cli::SheetArgs, + args: pixfix::cli::SheetArgs, config_file: &ConfigFile, -) -> Result<()> { + reporter: &Reporter, +) -> Result { let output_path = args.output_path(); - let overwrite = args.overwrite || config_file.output.overwrite.unwrap_or(false); - if output_path.exists() && !overwrite { - bail!( - "Output file already exists: {}. Use --overwrite to replace.", - output_path.display() - ); - } + let overwrite = resolved_overwrite(args.overwrite_opt(), config_file); + check_writable(&output_path, overwrite)?; + + // Config assembly first so flag/config errors surface before image I/O. + let file_options = config_file.to_options()?; + let (built, palette_label, grid_overridden) = build_config(&args.pipeline, config_file)?; + let pipeline_config = if args.no_normalize { None } else { Some(built) }; info!(path = %args.input.display(), "Loading sprite sheet"); let image = load_image(&args.input)?; - let (w, h) = (image.width(), image.height()); - eprintln!("Loaded {}x{} sprite sheet: {}", w, h, args.input.display()); + reporter.info(format_args!( + "Loaded {}x{} sprite sheet: {}", + image.width(), + image.height(), + args.input.display() + )); - match (args.tile_width, args.tile_height) { + let started = Instant::now(); + let (sheet, tiles, tile_w, tile_h) = match (args.tile_width, args.tile_height) { (Some(tw), Some(th)) => { - // Original fixed-grid mode - let (config, _) = build_pipeline_config(&args.pipeline, config_file)?; - - let (result, _tiles, _tw, _th) = normalize_pixelart::spritesheet::process_sheet( - &image, - tw, - th, - args.spacing, - args.margin, - &config, - ) - .context("Sprite sheet processing failed")?; - - let (ow, oh) = (result.width(), result.height()); - eprintln!("Output: {}x{}", ow, oh); - - save_image(&result, &output_path)?; - eprintln!("Saved: {}", output_path.display()); + let config = pipeline_config.ok_or_else(|| { + NormalizeError::InvalidInput( + "--no-normalize is not supported in fixed-grid mode".to_string(), + ) + })?; + pixfix::spritesheet::process_sheet(&image, tw, th, args.spacing, args.margin, &config)? } (None, None) => { - // Auto-split mode - use normalize_pixelart::spritesheet::{AutoSplitConfig, process_sheet_auto}; - - // Parse bg_color from pipeline flags or config - let bg_color = if let Some(ref hex) = args.pipeline.bg_color { - Some(parse_hex_color(hex).map_err(|e| anyhow::anyhow!(e))?) - } else if let Some(ref hex) = config_file.background.color { - Some(parse_hex_color(hex).map_err(|e| anyhow::anyhow!(e))?) - } else { - None - }; + use pixfix::spritesheet::{process_sheet_auto, AutoSplitConfig}; + let bg_color = args.pipeline.bg_color.or(file_options.bg_color); let tolerance = args .pipeline .bg_tolerance - .or(config_file.background.color_tolerance) + .or(file_options.bg_color_tolerance) .unwrap_or(0.05); let auto_config = AutoSplitConfig { @@ -305,129 +814,233 @@ fn run_sheet_command( .min_sprite_size .or(config_file.sheet.min_sprite_size) .unwrap_or(8), - pad: args - .pad - .or(config_file.sheet.pad) - .unwrap_or(0), + pad: args.pad.or(config_file.sheet.pad).unwrap_or(0), }; - let pipeline_config = if args.no_normalize { - None - } else { - let (config, _) = build_pipeline_config(&args.pipeline, config_file)?; - Some(config) - }; - - let (sheet, tiles, tile_w, tile_h) = - process_sheet_auto(&image, &auto_config, pipeline_config.as_ref()) - .context("Auto-split sprite sheet processing failed")?; - - let (ow, oh) = (sheet.width(), sheet.height()); - eprintln!("Output: {}x{} ({} sprites, {}x{} each)", ow, oh, tiles.len(), tile_w, tile_h); - - save_image(&sheet, &output_path)?; - eprintln!("Saved: {}", output_path.display()); - - // Optionally save individual sprite files - if let Some(ref dir) = args.output_dir { - std::fs::create_dir_all(dir) - .with_context(|| format!("Failed to create output directory: {}", dir.display()))?; - for tile in &tiles { - let filename = format!("sprite_{:02}_{:02}.png", tile.row, tile.col); - let path = dir.join(&filename); - save_image(&tile.image, &path)?; - } - eprintln!("Saved {} individual sprites to {}", tiles.len(), dir.display()); - } + process_sheet_auto(&image, &auto_config, pipeline_config.as_ref())? } _ => { - bail!( - "Either both --tile-width and --tile-height must be specified (fixed grid mode), \ - or neither (auto-split mode)." - ); + return Err(NormalizeError::InvalidInput( + "either both --tile-width and --tile-height must be specified (fixed grid \ + mode), or neither (auto-split mode)" + .to_string(), + )); } + }; + let duration_ms = started.elapsed().as_millis() as u64; + + reporter.info(format_args!( + "Output: {}x{} ({} sprites, {}x{} each)", + sheet.width(), + sheet.height(), + tiles.len(), + tile_w, + tile_h + )); + + save_image_atomic(&sheet, &output_path, OutputFormat::Png)?; + reporter.info(format_args!("Saved: {}", output_path.display())); + + // Optionally save individual sprite files (same overwrite policy as + // every other output). + let mut sprites_dir = None; + if let Some(ref dir) = args.output_dir { + std::fs::create_dir_all(dir).map_err(|source| NormalizeError::Io { + path: dir.clone(), + source, + })?; + for tile in &tiles { + let filename = format!("sprite_{:02}_{:02}.png", tile.row, tile.col); + let path = dir.join(&filename); + check_writable(&path, overwrite)?; + save_image_atomic(&tile.image, &path, OutputFormat::Png)?; + } + reporter.info(format_args!( + "Saved {} individual sprites to {}", + tiles.len(), + dir.display() + )); + sprites_dir = Some(dir.display().to_string()); } - Ok(()) + let report = SheetReport { + process: ProcessReport { + input: args.input.display().to_string(), + output: output_path.display().to_string(), + grid: None, + grid_best_guess: None, + logical_size: None, + output_size: [sheet.width(), sheet.height()], + frames: 1, + palette: palette_label, + colors_before: None, + colors_after: None, + aa_pixels_changed: None, + aa_passes: 0, + bg_removed: false, + bg_color: None, + bg_pixels_removed: None, + dither_pairs: Vec::new(), + low_confidence_blocks: 0, + total_blocks: 0, + warnings: if grid_overridden { + Vec::new() + } else { + vec!["per-tile pipeline details are not aggregated for sheets".to_string()] + }, + duration_ms, + }, + sprites: tiles.len() as u32, + tile_size: [tile_w, tile_h], + sprites_dir, + }; + reporter.emit(&report); + + Ok(ExitCode::SUCCESS) } -fn run_palette_list() -> Result<()> { - eprintln!("Built-in palettes:\n"); - for pal in palettes::ALL_PALETTES { - println!(" {:<16} {:>3} colors (--palette {})", pal.name, pal.colors.len(), pal.slug); +fn run_palette_list(reporter: &Reporter) -> Result { + reporter.info("Built-in palettes:\n"); + if reporter.is_json() { + let doc: Vec = palettes::ALL_PALETTES + .iter() + .map(|p| { + serde_json::json!({ + "slug": p.slug, + "name": p.name, + "colors": p.colors.len(), + }) + }) + .collect(); + reporter.emit(&doc); + } else { + for pal in palettes::ALL_PALETTES { + println!( + " {:<16} {:>3} colors (--palette {})", + pal.name, + pal.colors.len(), + pal.slug + ); + } } - Ok(()) + Ok(ExitCode::SUCCESS) } #[cfg(feature = "lospec")] -fn run_palette_fetch(args: normalize_pixelart::cli::PaletteFetchArgs) -> Result<()> { - use normalize_pixelart::color::lospec; +fn run_palette_fetch(args: pixfix::cli::PaletteFetchArgs, reporter: &Reporter) -> Result { + use pixfix::color::lospec; - eprintln!("Fetching palette '{}' from Lospec...", args.slug); - let pal = lospec::fetch_lospec_palette(&args.slug) - .map_err(|e| anyhow::anyhow!(e))?; + reporter.info(format_args!( + "Fetching palette '{}' from Lospec...", + args.slug + )); + let pal = lospec::fetch_lospec_palette(&args.slug, args.refresh)?; - eprintln!("{} ({} colors)\n", pal.name, pal.colors.len()); - for color in &pal.colors { - println!("#{:02X}{:02X}{:02X}", color[0], color[1], color[2]); + reporter.info(format_args!("{} ({} colors)", pal.name, pal.colors.len())); + if reporter.is_json() { + let doc = serde_json::json!({ + "name": pal.name, + "slug": pal.slug, + "colors": pal.colors.iter().map(|c| hex(*c)).collect::>(), + }); + reporter.emit(&doc); + } else { + for color in &pal.colors { + println!("#{:02X}{:02X}{:02X}", color[0], color[1], color[2]); + } } if let Some(ref output) = args.output { + check_writable(output, args.overwrite)?; let content = lospec::palette_to_hex_string(&pal); - std::fs::write(output, content) - .with_context(|| format!("Failed to write palette file: {}", output.display()))?; - eprintln!("\nSaved palette to {}", output.display()); + std::fs::write(output, content).map_err(|source| NormalizeError::Io { + path: output.clone(), + source, + })?; + reporter.info(format_args!("Saved palette to {}", output.display())); } - Ok(()) + Ok(ExitCode::SUCCESS) } fn run_palette_extract( - args: normalize_pixelart::cli::PaletteExtractArgs, -) -> Result<()> { - use normalize_pixelart::color::kmeans::{kmeans_oklab, subsample}; - use normalize_pixelart::color::oklab::rgba_to_oklab; - use palette::{IntoColor, Srgb}; + args: pixfix::cli::PaletteExtractArgs, + reporter: &Reporter, +) -> Result { + use pixfix::color::kmeans::{kmeans_weighted, weighted_medoids, KmeansConfig}; + use pixfix::color::oklab::rgba_to_oklab; + use pixfix::image_util::histogram::ColorHistogram; let image = load_image(&args.input)?; - eprintln!( + reporter.info(format_args!( "Extracting {} colors from {}...", args.colors, args.input.display() - ); + )); - // Collect opaque pixel colors - let all_colors: Vec = image - .pixels() - .filter(|p| p[3] > 0) - .map(|p| rgba_to_oklab(*p)) + // Weighted k-means over unique opaque colors (exact — no subsampling). + let entries = ColorHistogram::from_image(&image).opaque_rgb_counts(); + if entries.is_empty() { + return Err(NormalizeError::Palette( + "image has no opaque pixels".to_string(), + )); + } + let points: Vec = entries + .iter() + .map(|&(rgb, _)| rgba_to_oklab(image::Rgba([rgb[0], rgb[1], rgb[2], 255]))) .collect(); + let weights: Vec = entries.iter().map(|&(_, count)| count).collect(); - if all_colors.is_empty() { - bail!("Image has no opaque pixels"); - } + let result = kmeans_weighted( + &points, + &weights, + &[], + &KmeansConfig { + k: args.colors as usize, + max_iterations: 50, + seed: 0, + }, + ); + let medoids = weighted_medoids( + &points, + &weights, + &result.assignments, + result.centroids.len(), + ); - let samples = subsample(&all_colors, 10000); - let centroids = kmeans_oklab(&samples, args.colors as usize, 50); + // Emit each cluster's medoid — a real image color — in sorted order so + // repeated runs produce identical files. + let mut hex_lines: Vec = medoids + .into_iter() + .flatten() + .map(|m| { + let rgb = entries[m].0; + format!("{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) + }) + .collect(); + hex_lines.sort(); - // Convert to RGB and display - let mut hex_lines = Vec::new(); - for c in ¢roids { - let srgb: Srgb = (*c).into_color(); - let r = (srgb.red.clamp(0.0, 1.0) * 255.0).round() as u8; - let g = (srgb.green.clamp(0.0, 1.0) * 255.0).round() as u8; - let b = (srgb.blue.clamp(0.0, 1.0) * 255.0).round() as u8; - let hex = format!("{:02X}{:02X}{:02X}", r, g, b); - println!("#{}", hex); - hex_lines.push(hex); + if reporter.is_json() { + let doc = serde_json::json!({ + "input": args.input.display().to_string(), + "colors": hex_lines.iter().map(|h| format!("#{}", h)).collect::>(), + }); + reporter.emit(&doc); + } else { + for hex_line in &hex_lines { + println!("#{}", hex_line); + } } if let Some(ref output) = args.output { + check_writable(output, args.overwrite)?; let content = hex_lines.join("\n") + "\n"; - std::fs::write(output, content) - .with_context(|| format!("Failed to write palette file: {}", output.display()))?; - eprintln!("Saved palette to {}", output.display()); + std::fs::write(output, content).map_err(|source| NormalizeError::Io { + path: output.clone(), + source, + })?; + reporter.info(format_args!("Saved palette to {}", output.display())); } - Ok(()) + Ok(ExitCode::SUCCESS) } diff --git a/src/parallel.rs b/src/parallel.rs new file mode 100644 index 0000000..fd3fbd9 --- /dev/null +++ b/src/parallel.rs @@ -0,0 +1,84 @@ +//! Parallel-iterator seam: rayon when the `parallel` feature is on, serial +//! stand-ins when it's off (wasm32 has no threads). +//! +//! Call sites write `use crate::parallel::*;` and the same +//! `par_iter`/`into_par_iter` chains compile either way. The serial side +//! implements exactly the adapter surface this crate uses — `map`, `zip`, +//! `enumerate`, `collect`, and `fold(..).reduce(..)` — nothing more. + +#[cfg(feature = "parallel")] +pub use rayon::prelude::*; + +#[cfg(not(feature = "parallel"))] +pub use serial::*; + +// Which adapters are live depends on the feature set (batch is the only +// `zip`/`enumerate` user and it's cli-gated), so don't warn per-combination. +#[cfg(not(feature = "parallel"))] +#[allow(dead_code)] +mod serial { + /// Serial stand-in for rayon's parallel iterators. + pub struct SerialIter(I); + + /// `into_par_iter()` for owned collections and ranges. + pub trait IntoParallelIterator: IntoIterator + Sized { + fn into_par_iter(self) -> SerialIter { + SerialIter(self.into_iter()) + } + } + + impl IntoParallelIterator for T {} + + /// `par_iter()` for anything iterable by reference. + pub trait IntoParallelRefIterator<'a> { + type Iter: Iterator; + fn par_iter(&'a self) -> SerialIter; + } + + impl<'a, C: 'a + ?Sized> IntoParallelRefIterator<'a> for C + where + &'a C: IntoIterator, + { + type Iter = <&'a C as IntoIterator>::IntoIter; + fn par_iter(&'a self) -> SerialIter { + SerialIter(self.into_iter()) + } + } + + impl SerialIter { + pub fn map B>(self, f: F) -> SerialIter> { + SerialIter(self.0.map(f)) + } + + pub fn zip(self, other: SerialIter) -> SerialIter> { + SerialIter(self.0.zip(other.0)) + } + + pub fn enumerate(self) -> SerialIter> { + SerialIter(self.0.enumerate()) + } + + pub fn collect>(self) -> B { + self.0.collect() + } + + /// Rayon folds per worker and reduces the partials; serially there is + /// exactly one partial, so `reduce` afterwards just unwraps it. + pub fn fold(self, identity: ID, fold_op: F) -> SerialFold + where + ID: FnOnce() -> T, + F: FnMut(T, I::Item) -> T, + { + SerialFold(self.0.fold(identity(), fold_op)) + } + } + + /// Result of a serial `fold`; `reduce` returns the single accumulator. + pub struct SerialFold(T); + + impl SerialFold { + pub fn reduce T, F: FnOnce(T, T) -> T>(self, _identity: ID, _op: F) -> T { + self.0 + } + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..8533bc5 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,82 @@ +//! Output-path derivation and overwrite policy, shared by every command. + +use std::path::{Path, PathBuf}; + +use crate::error::{NormalizeError, Result}; + +/// How derived output files are named. +pub struct OutputNaming<'a> { + /// Appended to the input stem (default "_normalized"; may be empty). + pub suffix: &'a str, + /// Output extension WITHOUT the dot. Always the requested output + /// format's extension — never copied from the input: the pipeline + /// produces RGBA and e.g. RGBA JPEG cannot be encoded, which used to + /// fail after the whole pipeline had run. + pub extension: &'a str, +} + +impl Default for OutputNaming<'_> { + fn default() -> Self { + Self { + suffix: "_normalized", + extension: "png", + } + } +} + +/// Derive an output path: `.`, placed in +/// `output_dir` when given, else next to the input. +pub fn derive_output_path( + input: &Path, + output_dir: Option<&Path>, + naming: &OutputNaming, +) -> PathBuf { + let stem = input.file_stem().unwrap_or_default().to_string_lossy(); + let filename = format!("{}{}.{}", stem, naming.suffix, naming.extension); + match output_dir { + Some(dir) => dir.join(filename), + None => input.with_file_name(filename), + } +} + +/// The one overwrite check: error unless the path is free or overwrite is on. +pub fn check_writable(path: &Path, overwrite: bool) -> Result<()> { + if path.exists() && !overwrite { + return Err(NormalizeError::OutputExists(path.to_path_buf())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extension_never_copied_from_input() { + let out = derive_output_path(Path::new("photos/cat.jpg"), None, &OutputNaming::default()); + assert_eq!(out, PathBuf::from("photos/cat_normalized.png")); + } + + #[test] + fn test_output_dir_and_custom_suffix() { + let naming = OutputNaming { + suffix: "", + extension: "webp", + }; + let out = derive_output_path(Path::new("a/b/x.png"), Some(Path::new("out")), &naming); + assert_eq!(out, PathBuf::from("out/x.webp")); + } + + #[test] + fn test_check_writable() { + assert!(check_writable(Path::new("/definitely/not/there.png"), false).is_ok()); + let f = std::env::temp_dir().join("np_check_writable_test.png"); + std::fs::write(&f, b"x").unwrap(); + assert!(matches!( + check_writable(&f, false), + Err(NormalizeError::OutputExists(_)) + )); + assert!(check_writable(&f, true).is_ok()); + let _ = std::fs::remove_file(&f); + } +} diff --git a/src/pipeline/aa_removal.rs b/src/pipeline/aa_removal.rs index c6d1666..13c0a02 100644 --- a/src/pipeline/aa_removal.rs +++ b/src/pipeline/aa_removal.rs @@ -1,19 +1,33 @@ -use anyhow::Result; +use crate::error::Result; +use crate::parallel::*; use image::Rgba; -use palette::Oklab; -use rayon::prelude::*; use tracing::info; -use crate::color::oklab::{oklab_distance, rgba_to_oklab}; +use crate::color::oklab::{is_between_oklab, oklab_distance, rgba_to_oklab}; +use crate::image_util::neighbors::OFFSETS; use crate::pipeline::PipelineState; +/// Neighbors more transparent than this don't vote — their RGB is blend +/// residue or outright garbage, and letting them in painted dark halos +/// around sprites on transparent backgrounds. +const MIN_NEIGHBOR_ALPHA: u8 = 128; + /// Configuration for anti-aliasing removal. #[derive(Debug, Clone)] pub struct AaRemovalConfig { - /// Sensitivity threshold (0.0-1.0). Lower = more aggressive removal. + /// Sensitivity threshold (0.0-1.0). HIGHER = more aggressive removal. /// A pixel is considered AA if it lies on the interpolation line between /// two neighbor colors within this tolerance. pub threshold: f32, + /// Maximum passes. 10x AI upscales carry 2-3px AA ramps whose middle + /// pixels shield each other within a single pass; iterating to a + /// fixpoint (early-stopped) collapses them. + pub max_passes: u32, + /// Never rewrite a pixel whose exact color appears at least twice among + /// its opaque neighbors: checkerboard dither partners always have >= 2 + /// exact diagonal matches, so ordered dithering survives multi-pass + /// removal while lone blend pixels (0-1 matches) still collapse. + pub protect_dither: bool, /// Whether to skip AA removal entirely. pub skip: bool, } @@ -22,34 +36,56 @@ impl Default for AaRemovalConfig { fn default() -> Self { Self { threshold: 0.5, + max_passes: 3, + protect_dither: true, skip: true, // Off by default — AI art has intentional edge detail } } } -/// The 8 directions for 8-connected neighborhood. -const OFFSETS: [(i32, i32); 8] = [ - (-1, -1), (0, -1), (1, -1), - (-1, 0), (1, 0), - (-1, 1), (0, 1), (1, 1), -]; - /// Remove anti-aliasing artifacts from the image. /// -/// For each pixel, examines its 8-connected neighbors. If the pixel's color -/// lies "between" the two most common neighbor colors in OKLAB space -/// (i.e., it's an interpolation artifact), snaps it to the closer neighbor. +/// For each pixel, examines its 8-connected opaque neighbors. If the +/// pixel's color lies "between" the two most common neighbor colors in +/// OKLAB space (i.e., it's an interpolation artifact), snaps it to the +/// closer neighbor. Runs up to `max_passes` passes, stopping early when a +/// pass changes nothing. pub fn remove_aa(state: &mut PipelineState, config: &AaRemovalConfig) -> Result<()> { if config.skip { return Ok(()); } + let mut total_changed: u64 = 0; + let mut passes_run: u32 = 0; + + for _pass in 0..config.max_passes.max(1) { + let changed = run_pass(state, config); + passes_run += 1; + total_changed += changed as u64; + if changed == 0 { + break; + } + } + + state.diagnostics.aa_pixels_changed = Some(total_changed); + state.diagnostics.aa_passes_run = passes_run; + info!( + removed = total_changed, + passes = passes_run, + "AA removal complete" + ); + + Ok(()) +} + +/// One AA-removal pass. Returns the number of pixels rewritten. +fn run_pass(state: &mut PipelineState, config: &AaRemovalConfig) -> u32 { let image = &state.image; let width = image.width(); let height = image.height(); // Pre-compute OKLAB values for the entire image (avoids repeated conversions) - let oklab_pixels: Vec = image.pixels().map(|p| rgba_to_oklab(*p)).collect(); + let oklab_pixels: Vec = image.pixels().map(|p| rgba_to_oklab(*p)).collect(); let raw_pixels: Vec<[u8; 4]> = image.pixels().map(|p| p.0).collect(); // Process rows in parallel, collect results @@ -67,32 +103,37 @@ pub fn remove_aa(state: &mut PipelineState, config: &AaRemovalConfig) -> Result< continue; } - // Get the two most common neighbor colors (inline, no allocation) - let (c1, c2, n_unique) = top_two_neighbors( - &raw_pixels, width, height, x, y, - ); + let nb = scan_neighbors(&raw_pixels, width, height, x, y, pixel); - if n_unique < 2 { + if nb.n_unique < 2 { row_results.push(None); continue; } // If the pixel is already one of the dominant colors, skip - if pixel == c1 || pixel == c2 { + if pixel == nb.c1 || pixel == nb.c2 { + row_results.push(None); + continue; + } + + // Dither guard: a checkerboard pixel always has exact color + // twins among its diagonals — leave it alone. + if config.protect_dither && nb.self_matches >= 2 { row_results.push(None); continue; } - // Check if the pixel lies between c1 and c2 in OKLAB space + // Check if the pixel lies between c1 and c2 in OKLAB space, + // reusing the precomputed conversions via neighbor indices. let p_ok = oklab_pixels[idx]; - let c1_ok = rgba_to_oklab(Rgba(c1)); - let c2_ok = rgba_to_oklab(Rgba(c2)); + let c1_ok = oklab_pixels[nb.c1_idx]; + let c2_ok = oklab_pixels[nb.c2_idx]; if is_between_oklab(p_ok, c1_ok, c2_ok, config.threshold) { // Snap to the closer of c1 or c2 let d1 = oklab_distance(p_ok, c1_ok); let d2 = oklab_distance(p_ok, c2_ok); - let snapped = if d1 <= d2 { c1 } else { c2 }; + let snapped = if d1 <= d2 { nb.c1 } else { nb.c2 }; // Preserve original alpha row_results.push(Some(Rgba([snapped[0], snapped[1], snapped[2], pixel[3]]))); } else { @@ -115,28 +156,39 @@ pub fn remove_aa(state: &mut PipelineState, config: &AaRemovalConfig) -> Result< } } - info!( - removed = removed_count, - total = width * height, - "AA removal complete" - ); - state.image = output; - Ok(()) + removed_count +} + +/// Result of one neighborhood scan. +struct NeighborScan { + /// Most common opaque neighbor color and a pixel index bearing it. + c1: [u8; 4], + c1_idx: usize, + /// Second most common, likewise. + c2: [u8; 4], + c2_idx: usize, + /// Number of unique opaque neighbor colors. + n_unique: usize, + /// How many opaque neighbors exactly match the center pixel's color. + self_matches: u32, } -/// Find the top two most frequent neighbor colors (no heap allocation). -/// Returns (most_common, second_common, unique_count). -fn top_two_neighbors( +/// Find the top two most frequent opaque neighbor colors (no heap +/// allocation), plus the center pixel's exact-match count for the dither +/// guard — all in a single scan. +fn scan_neighbors( pixels: &[[u8; 4]], width: u32, height: u32, x: u32, y: u32, -) -> ([u8; 4], [u8; 4], usize) { - // At most 8 neighbors; use fixed-size stack buffer - let mut colors: [([u8; 4], u8); 8] = [([0; 4], 0); 8]; + center: [u8; 4], +) -> NeighborScan { + // At most 8 neighbors; use fixed-size stack buffers + let mut colors: [([u8; 4], u8, usize); 8] = [([0; 4], 0, 0); 8]; let mut n_unique = 0usize; + let mut self_matches = 0u32; let ix = x as i32; let iy = y as i32; @@ -147,7 +199,16 @@ fn top_two_neighbors( let nx = ix + dx; let ny = iy + dy; if nx >= 0 && nx < w && ny >= 0 && ny < h { - let pixel = pixels[(ny as u32 * width + nx as u32) as usize]; + let idx = (ny as u32 * width + nx as u32) as usize; + let pixel = pixels[idx]; + // Transparent-ish neighbors carry blend residue or garbage RGB; + // they must not become snap targets. + if pixel[3] < MIN_NEIGHBOR_ALPHA { + continue; + } + if pixel == center { + self_matches += 1; + } // Linear search in our small fixed buffer let mut found = false; for entry in colors.iter_mut().take(n_unique) { @@ -158,14 +219,21 @@ fn top_two_neighbors( } } if !found && n_unique < 8 { - colors[n_unique] = (pixel, 1); + colors[n_unique] = (pixel, 1, idx); n_unique += 1; } } } if n_unique < 2 { - return (colors[0].0, [0; 4], n_unique); + return NeighborScan { + c1: colors[0].0, + c1_idx: colors[0].2, + c2: [0; 4], + c2_idx: 0, + n_unique, + self_matches, + }; } // Find top two by count (partial sort, no allocation) @@ -183,33 +251,14 @@ fn top_two_neighbors( } } - (colors[first_idx].0, colors[second_idx].0, n_unique) -} - -/// Check if a point lies between two colors in OKLAB space. -/// -/// Uses the triangle inequality: if dist(c1, p) + dist(p, c2) ≈ dist(c1, c2), -/// then p is roughly on the line segment between c1 and c2. -fn is_between_oklab(p: Oklab, c1: Oklab, c2: Oklab, threshold: f32) -> bool { - let d_c1_c2 = oklab_distance(c1, c2); - - // Colors must be sufficiently different for AA to be meaningful - if d_c1_c2 < 0.02 { - return false; + NeighborScan { + c1: colors[first_idx].0, + c1_idx: colors[first_idx].2, + c2: colors[second_idx].0, + c2_idx: colors[second_idx].2, + n_unique, + self_matches, } - - let d_c1_p = oklab_distance(c1, p); - let d_p_c2 = oklab_distance(p, c2); - - // Triangle inequality deviation - let deviation = (d_c1_p + d_p_c2) / d_c1_c2 - 1.0; - - // Must not be too close to either endpoint - let ratio = d_c1_p / d_c1_c2; - let away_from_endpoints = ratio > 0.1 && ratio < 0.9; - - let max_deviation = threshold * 0.3; - deviation < max_deviation && away_from_endpoints } #[cfg(test)] @@ -219,81 +268,198 @@ mod tests { use crate::pipeline::PipelineState; use image::RgbaImage; - #[test] - fn test_is_between_exact_midpoint() { - let red = Rgba([255, 0, 0, 255]); - let blue = Rgba([0, 0, 255, 255]); - let blend = Rgba([127, 0, 127, 255]); - let p = rgba_to_oklab(blend); - let c1 = rgba_to_oklab(red); - let c2 = rgba_to_oklab(blue); - assert!(is_between_oklab(p, c1, c2, 0.5)); - } - - #[test] - fn test_is_not_between_same_color() { - let red = Rgba([255, 0, 0, 255]); - let p = rgba_to_oklab(red); - assert!(!is_between_oklab(p, p, p, 0.5)); - } - - #[test] - fn test_is_not_between_endpoint() { - let red = Rgba([255, 0, 0, 255]); - let blue = Rgba([0, 0, 255, 255]); - let p = rgba_to_oklab(red); - let c1 = rgba_to_oklab(red); - let c2 = rgba_to_oklab(blue); - assert!(!is_between_oklab(p, c1, c2, 0.5)); + fn config() -> AaRemovalConfig { + AaRemovalConfig { + threshold: 0.5, + skip: false, + ..Default::default() + } } #[test] fn test_aa_removal_snaps_blend_pixels() { - // 3x3 image: red border with a blend pixel in the center-right + // 5x3 image: red left, blue right, blend pixel at the boundary let red = Rgba([255, 0, 0, 255]); let blue = Rgba([0, 0, 255, 255]); let blend = Rgba([127, 0, 127, 255]); let mut img = RgbaImage::new(5, 3); - // Fill left side with red for y in 0..3 { for x in 0..3 { img.put_pixel(x, y, red); } } - // Right side blue for y in 0..3 { img.put_pixel(3, y, blue); img.put_pixel(4, y, blue); } - // Insert blend pixel at the boundary img.put_pixel(2, 1, blend); let mut state = PipelineState::new(img); - let config = AaRemovalConfig { - threshold: 0.5, - skip: false, - }; - remove_aa(&mut state, &config).unwrap(); + remove_aa(&mut state, &config()).unwrap(); - // The blend pixel should have been snapped to red (closer neighbor) let result = *state.image.get_pixel(2, 1); - assert!(result == red || result == blue, "Expected red or blue, got {:?}", result); + assert!( + result == red || result == blue, + "Expected red or blue, got {:?}", + result + ); + assert_eq!(state.diagnostics.aa_pixels_changed, Some(1)); + } + + #[test] + fn test_transparent_neighbors_never_become_targets() { + // A sprite edge against transparent background: the transparent + // pixels' (0,0,0,0) RGB must not be chosen as a snap target — that + // painted dark halos. + let red = Rgba([255, 0, 0, 255]); + let pink = Rgba([255, 128, 128, 255]); // sprite-edge blend + let clear = Rgba([0, 0, 0, 0]); + + let mut img = RgbaImage::new(3, 3); + for y in 0..3 { + img.put_pixel(0, y, red); + img.put_pixel(1, y, pink); + img.put_pixel(2, y, clear); + } + + let mut state = PipelineState::new(img); + remove_aa(&mut state, &config()).unwrap(); + + // Whatever happened, no pixel may have become black. + for p in state.image.pixels() { + if p[3] > 0 { + assert_ne!( + [p[0], p[1], p[2]], + [0, 0, 0], + "transparent RGB leaked as a snap target" + ); + } + } + } + + #[test] + fn test_multi_pass_collapses_wide_ramp() { + // 2px AA ramp: red | b1 | b2 | blue. The two middle pixels shield + // each other in a single pass; three passes must collapse both. + let red = Rgba([255, 0, 0, 255]); + let b1 = Rgba([191, 0, 63, 255]); + let b2 = Rgba([63, 0, 191, 255]); + let blue = Rgba([0, 0, 255, 255]); + + let mut img = RgbaImage::new(6, 3); + for y in 0..3 { + img.put_pixel(0, y, red); + img.put_pixel(1, y, red); + img.put_pixel(2, y, b1); + img.put_pixel(3, y, b2); + img.put_pixel(4, y, blue); + img.put_pixel(5, y, blue); + } + + let mut state = PipelineState::new(img); + remove_aa(&mut state, &config()).unwrap(); + + for y in 0..3 { + for x in 0..6 { + let p = *state.image.get_pixel(x, y); + assert!( + p == red || p == blue, + "ramp residue at ({}, {}): {:?}", + x, + y, + p + ); + } + } + assert!(state.diagnostics.aa_passes_run >= 2); + } + + #[test] + fn test_dither_guard_preserves_checkerboard() { + // A checkerboard of two colors that happen to lie on each other's + // interpolation line must survive with the guard on and erode with + // it off. + let a = Rgba([100, 100, 100, 255]); + let b = Rgba([160, 160, 160, 255]); + let make = || { + let mut img = RgbaImage::new(8, 8); + for y in 0..8 { + for x in 0..8 { + img.put_pixel(x, y, if (x + y) % 2 == 0 { a } else { b }); + } + } + img + }; + + // Guard on: nothing changes. + let mut state = PipelineState::new(make()); + remove_aa(&mut state, &config()).unwrap(); + assert_eq!(state.diagnostics.aa_pixels_changed, Some(0)); + + // Note: a pure two-color checkerboard is also protected by the + // pixel==c1||c2 shortcut; the guard matters when a third color is + // around. Add a gradient stripe through the middle. + let mut img = make(); + for y in 0..8 { + img.put_pixel(4, y, Rgba([130, 130, 130, 255])); + } + let mut state = PipelineState::new(img.clone()); + remove_aa(&mut state, &config()).unwrap(); + // The checkerboard region away from the stripe is intact. + for y in 0..8 { + for x in 0..3 { + assert_eq!( + *state.image.get_pixel(x, y), + *img.get_pixel(x, y), + "checkerboard eroded at ({}, {})", + x, + y + ); + } + } + } + + #[test] + fn test_higher_threshold_removes_at_least_as_much() { + let mut img = RgbaImage::new(8, 8); + let mut seed = 99u32; + for p in img.pixels_mut() { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let v = (seed >> 24) as u8; + *p = Rgba([v, v / 2, 255 - v, 255]); + } + + let removed_at = |t: f32| { + let mut state = PipelineState::new(img.clone()); + let cfg = AaRemovalConfig { + threshold: t, + max_passes: 1, + protect_dither: false, + skip: false, + }; + remove_aa(&mut state, &cfg).unwrap(); + state.diagnostics.aa_pixels_changed.unwrap() + }; + assert!( + removed_at(0.9) >= removed_at(0.2), + "higher threshold must be at least as aggressive" + ); } #[test] - fn test_top_two_neighbors_matches_original() { - // Verify the optimized version gives same results as the original + fn test_scan_neighbors_matches_reference() { + // Verify the optimized version agrees with the reference helper. let mut img = RgbaImage::from_pixel(3, 3, Rgba([255, 0, 0, 255])); img.put_pixel(0, 0, Rgba([0, 0, 255, 255])); img.put_pixel(1, 0, Rgba([0, 0, 255, 255])); let raw: Vec<[u8; 4]> = img.pixels().map(|p| p.0).collect(); - let (c1, c2, n) = top_two_neighbors(&raw, 3, 3, 1, 1); + let nb = scan_neighbors(&raw, 3, 3, 1, 1, raw[4]); let neighbors = neighbor_colors(&img, 1, 1); - assert_eq!(n, neighbors.len()); - assert_eq!(c1, neighbors[0].0.0); - assert_eq!(c2, neighbors[1].0.0); + assert_eq!(nb.n_unique, neighbors.len()); + assert_eq!(nb.c1, neighbors[0].0 .0); + assert_eq!(nb.c2, neighbors[1].0 .0); } } diff --git a/src/pipeline/background.rs b/src/pipeline/background.rs index 5e8bba0..0cdc579 100644 --- a/src/pipeline/background.rs +++ b/src/pipeline/background.rs @@ -1,9 +1,11 @@ -use anyhow::Result; +use crate::error::Result; use image::{Rgba, RgbaImage}; -use std::collections::VecDeque; +use palette::Oklab; use tracing::info; -use crate::color::oklab::{oklab_distance, rgba_to_oklab}; +use crate::color::oklab::{is_between_oklab, oklab_distance, rgba_to_oklab}; +use crate::image_util::flood::flood_fill_from_border; +use crate::image_util::histogram::ColorHistogram; /// Configuration for background detection and removal. #[derive(Debug, Clone)] @@ -12,12 +14,22 @@ pub struct BackgroundConfig { pub enabled: bool, /// Explicit background color (if None, auto-detect from border pixels). pub bg_color: Option<[u8; 3]>, - /// Minimum fraction of border pixels that must match for auto-detection (0.0-1.0). + /// Minimum fraction of border pixels the winning color cluster must + /// cover for auto-detection (0.0-1.0). pub border_threshold: f32, - /// OKLAB distance threshold for considering a pixel as "background color". + /// OKLAB distance threshold for considering a pixel "background color". pub color_tolerance: f32, - /// Use flood-fill from corners instead of global replacement. + /// Use flood-fill from the border instead of global replacement. pub flood_fill: bool, + /// Absorb the 1px AA fringe between background and sprite into the + /// mask (pixels that interpolate between the two). + pub defringe: bool, + /// Chroma keys: colors removed globally (everywhere in the image, no + /// flood fill, no border detection) within `chroma_tolerance`. Runs + /// independently of `enabled` and combines with it. + pub chroma_keys: Vec<[u8; 3]>, + /// OKLAB match tolerance for chroma keys. + pub chroma_tolerance: f32, } impl Default for BackgroundConfig { @@ -28,206 +40,348 @@ impl Default for BackgroundConfig { border_threshold: 0.4, color_tolerance: 0.05, flood_fill: true, + defringe: true, + chroma_keys: Vec::new(), + chroma_tolerance: 0.05, } } } -/// Detect and remove the background from the image. +/// A detected (or explicitly given) background. +#[derive(Debug, Clone, Copy)] +pub struct DetectedBackground { + /// The heaviest actual border color of the winning cluster (for + /// reporting). + pub color: Rgba, + /// Weighted OKLAB mean of the cluster — what pixels are matched against. + pub center: Oklab, + /// Fraction of border pixels the cluster covers. + pub coverage: f32, + /// Match tolerance for the fill: at least the configured tolerance, + /// stretched up to 2x when the cluster itself is that spread out + /// (gradient/vignette backgrounds). + pub fill_tolerance: f32, +} + +/// Detect the background color by clustering border pixels in OKLAB. /// -/// Two modes: -/// 1. **Flood-fill** (default): start from all four corners and flood-fill -/// connected regions of the background color, replacing with transparency. -/// This only removes the outer background, not interior regions. -/// 2. **Global**: replace ALL pixels matching the background color with -/// transparency, regardless of position. -pub fn remove_background( - image: &mut RgbaImage, +/// AI backgrounds are rarely bit-flat — a subtle vignette yields thousands +/// of unique border RGB values, which made the old exact-histogram approach +/// return nothing. Clustering merges everything within `color_tolerance` +/// of a seed (two-pass, mean-refined), trying the four heaviest seeds. +pub fn detect_background_color( + image: &RgbaImage, config: &BackgroundConfig, -) -> Result>> { - if !config.enabled { - return Ok(None); - } - +) -> Option { let width = image.width(); let height = image.height(); + if width < 2 || height < 2 { + return None; + } - // Determine background color - let bg_color = if let Some(rgb) = config.bg_color { - Rgba([rgb[0], rgb[1], rgb[2], 255]) - } else { - // Auto-detect from border pixels - match detect_border_color(image, config.border_threshold) { - Some(color) => color, - None => { - info!("No dominant border color detected, skipping background removal"); - return Ok(None); - } - } - }; - - info!( - r = bg_color[0], - g = bg_color[1], - b = bg_color[2], - "Detected background color" - ); + let border = ColorHistogram::from_pixels(border_pixels(image)); + let entries: Vec<([u8; 4], u32)> = entries_opaque(&border); + let total: u32 = entries.iter().map(|&(_, n)| n).sum(); + if total == 0 { + return None; + } - let bg_oklab = rgba_to_oklab(bg_color); - let mut removed_count = 0u32; - - if config.flood_fill { - // Flood-fill from corners - let mut visited = vec![false; (width * height) as usize]; - let mut queue = VecDeque::new(); - - // Seed from all four corners - let corners = [(0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1)]; - for &(cx, cy) in &corners { - let idx = (cy * width + cx) as usize; - if !visited[idx] && is_bg_pixel(image, cx, cy, bg_oklab, config.color_tolerance) { - queue.push_back((cx, cy)); - visited[idx] = true; + let oklabs: Vec = entries + .iter() + .map(|&(c, _)| rgba_to_oklab(Rgba(c))) + .collect(); + + let mut best: Option = None; + for seed in 0..entries.len().min(4) { + // Pass 1: merge around the seed color. + let mut center = oklabs[seed]; + for _pass in 0..2 { + let mut sum = (0.0f32, 0.0f32, 0.0f32, 0.0f32); + for (i, &(_, n)) in entries.iter().enumerate() { + if oklab_distance(oklabs[i], center) <= config.color_tolerance { + let w = n as f32; + sum.0 += oklabs[i].l * w; + sum.1 += oklabs[i].a * w; + sum.2 += oklabs[i].b * w; + sum.3 += w; + } + } + if sum.3 > 0.0 { + center = Oklab::new(sum.0 / sum.3, sum.1 / sum.3, sum.2 / sum.3); } } - // Also seed from all border pixels (not just corners) to handle - // backgrounds that don't touch corners - for x in 0..width { - for &y in &[0, height - 1] { - let idx = (y * width + x) as usize; - if !visited[idx] && is_bg_pixel(image, x, y, bg_oklab, config.color_tolerance) { - queue.push_back((x, y)); - visited[idx] = true; + // Collect members around the refined center. + let mut weight = 0u32; + let mut max_radius = 0.0f32; + let mut top: Option<([u8; 4], u32)> = None; + for (i, &(c, n)) in entries.iter().enumerate() { + let d = oklab_distance(oklabs[i], center); + if d <= config.color_tolerance { + weight += n; + max_radius = max_radius.max(d); + if top.map(|(_, tn)| n > tn).unwrap_or(true) { + top = Some((c, n)); } } } - for y in 1..height - 1 { - for &x in &[0, width - 1] { - let idx = (y * width + x) as usize; - if !visited[idx] && is_bg_pixel(image, x, y, bg_oklab, config.color_tolerance) { - queue.push_back((x, y)); - visited[idx] = true; - } - } + + let coverage = weight as f32 / total as f32; + if coverage < config.border_threshold { + continue; } + let candidate = DetectedBackground { + color: Rgba(top.unwrap().0), + center, + coverage, + fill_tolerance: (config.color_tolerance + max_radius) + .clamp(config.color_tolerance, 2.0 * config.color_tolerance), + }; + if best.map(|b| coverage > b.coverage).unwrap_or(true) { + best = Some(candidate); + } + } - // BFS flood-fill - while let Some((x, y)) = queue.pop_front() { - // Make this pixel transparent - image.put_pixel(x, y, Rgba([0, 0, 0, 0])); - removed_count += 1; - - // Check 4-connected neighbors - let neighbors = [ - (x.wrapping_sub(1), y), - (x + 1, y), - (x, y.wrapping_sub(1)), - (x, y + 1), - ]; - for (nx, ny) in neighbors { - if nx < width && ny < height { - let idx = (ny * width + nx) as usize; - if !visited[idx] - && is_bg_pixel(image, nx, ny, bg_oklab, config.color_tolerance) - { - visited[idx] = true; - queue.push_back((nx, ny)); - } - } - } + best +} + +/// Build the background mask (true = background) without clearing anything. +/// +/// Flood-fill mode seeds from the border and expands through matching (or +/// already-transparent) pixels; global mode marks every match. With +/// `defringe`, one dilation step absorbs opaque pixels that interpolate +/// between the background and their sprite-side neighbor — the 1px AA +/// fringe that otherwise survives as a halo. +pub fn build_bg_mask( + image: &RgbaImage, + bg: &DetectedBackground, + config: &BackgroundConfig, +) -> Vec { + let width = image.width(); + let height = image.height(); + + let matches = |x: u32, y: u32| -> bool { + let p = *image.get_pixel(x, y); + if p[3] == 0 { + return true; // transparent pixels are background by definition } + oklab_distance(rgba_to_oklab(p), bg.center) <= bg.fill_tolerance + }; + + let mut mask = if config.flood_fill { + flood_fill_from_border(width, height, matches) } else { - // Global replacement: replace ALL matching pixels + let mut m = vec![false; (width * height) as usize]; for y in 0..height { for x in 0..width { - if is_bg_pixel(image, x, y, bg_oklab, config.color_tolerance) { - image.put_pixel(x, y, Rgba([0, 0, 0, 0])); - removed_count += 1; + if matches(x, y) { + m[(y * width + x) as usize] = true; } } } - } + m + }; - info!( - removed = removed_count, - total = width * height, - pct = format!("{:.1}%", removed_count as f64 / (width * height) as f64 * 100.0), - "Background removal complete" - ); + if config.defringe { + defringe_pass(image, bg.center, &mut mask); + } - Ok(Some(bg_color)) + mask } -/// Check if a pixel matches the background color within tolerance. -pub fn is_bg_pixel( - image: &RgbaImage, - x: u32, - y: u32, - bg_oklab: palette::Oklab, - tolerance: f32, -) -> bool { - let pixel = *image.get_pixel(x, y); - if pixel[3] == 0 { - return false; // Already transparent +/// One dilation step: an unmasked opaque pixel 4-adjacent to the mask joins +/// it when its color lies between the background and the opposite-side +/// (sprite) neighbor — i.e., it is a blend of the two. +fn defringe_pass(image: &RgbaImage, center: Oklab, mask: &mut [bool]) { + let width = image.width(); + let height = image.height(); + let mut additions = Vec::new(); + + for y in 0..height { + for x in 0..width { + let idx = (y * width + x) as usize; + if mask[idx] || image.get_pixel(x, y)[3] == 0 { + continue; + } + // Directions: (toward-mask, toward-sprite) opposite pairs. + let dirs: [((i64, i64), (i64, i64)); 4] = [ + ((-1, 0), (1, 0)), + ((1, 0), (-1, 0)), + ((0, -1), (0, 1)), + ((0, 1), (0, -1)), + ]; + let p_ok = rgba_to_oklab(*image.get_pixel(x, y)); + for ((mx, my), (sx, sy)) in dirs { + let (bx, by) = (x as i64 + mx, y as i64 + my); + let (fx, fy) = (x as i64 + sx, y as i64 + sy); + if bx < 0 || by < 0 || bx >= width as i64 || by >= height as i64 { + continue; + } + if !mask[(by as u32 * width + bx as u32) as usize] { + continue; + } + if fx < 0 || fy < 0 || fx >= width as i64 || fy >= height as i64 { + continue; + } + let sprite = *image.get_pixel(fx as u32, fy as u32); + if sprite[3] == 0 || mask[(fy as u32 * width + fx as u32) as usize] { + continue; + } + if is_between_oklab(p_ok, center, rgba_to_oklab(sprite), 0.5) { + additions.push(idx); + break; + } + } + } + } + + for idx in additions { + mask[idx] = true; } - let pixel_oklab = rgba_to_oklab(pixel); - oklab_distance(pixel_oklab, bg_oklab) <= tolerance } -/// Auto-detect background color from border pixels. -/// -/// Samples all pixels on the image border, builds a histogram, -/// and returns the most common color if it exceeds the threshold. -pub fn detect_border_color(image: &RgbaImage, threshold: f32) -> Option> { +/// Build a chroma-key mask: every pixel within `tolerance` of any key is +/// marked, globally — no flood fill, no border detection. With `defringe`, +/// one dilation per key absorbs the AA fringe around keyed regions. +pub fn build_chroma_mask( + image: &RgbaImage, + keys: &[[u8; 3]], + tolerance: f32, + defringe: bool, +) -> Vec { let width = image.width(); let height = image.height(); - - if width < 2 || height < 2 { - return None; + let mut mask = vec![false; (width * height) as usize]; + if keys.is_empty() { + return mask; } - let mut color_counts: Vec<([u8; 4], u32)> = Vec::new(); - let mut total_border = 0u32; + let key_oklabs: Vec = keys + .iter() + .map(|k| rgba_to_oklab(Rgba([k[0], k[1], k[2], 255]))) + .collect(); - // Collect all border pixels - let mut add_pixel = |x: u32, y: u32| { - let pixel = image.get_pixel(x, y).0; - if pixel[3] == 0 { - return; // Skip transparent + for y in 0..height { + for x in 0..width { + let p = *image.get_pixel(x, y); + if p[3] == 0 { + mask[(y * width + x) as usize] = true; + continue; + } + let ok = rgba_to_oklab(p); + if key_oklabs + .iter() + .any(|k| oklab_distance(ok, *k) <= tolerance) + { + mask[(y * width + x) as usize] = true; + } } - total_border += 1; - if let Some(entry) = color_counts.iter_mut().find(|(c, _)| *c == pixel) { - entry.1 += 1; - } else { - color_counts.push((pixel, 1)); + } + + if defringe { + for key in &key_oklabs { + defringe_pass(image, *key, &mut mask); } - }; + } - // Top and bottom rows - for x in 0..width { - add_pixel(x, 0); - add_pixel(x, height - 1); + mask +} + +/// Clear masked pixels to transparent. Returns the count cleared. +pub fn apply_bg_mask(image: &mut RgbaImage, mask: &[bool]) -> u32 { + let width = image.width(); + let mut removed = 0u32; + for (y, x) in (0..image.height()).flat_map(|y| (0..width).map(move |x| (y, x))) { + if mask[(y * width + x) as usize] && image.get_pixel(x, y)[3] != 0 { + image.put_pixel(x, y, Rgba([0, 0, 0, 0])); + removed += 1; + } } - // Left and right columns (excluding corners already counted) - for y in 1..height - 1 { - add_pixel(0, y); - add_pixel(width - 1, y); + removed +} + +/// Resolve the background for a config: explicit color or auto-detected. +pub fn resolve_background( + image: &RgbaImage, + config: &BackgroundConfig, +) -> Option { + if let Some(rgb) = config.bg_color { + let color = Rgba([rgb[0], rgb[1], rgb[2], 255]); + Some(DetectedBackground { + color, + center: rgba_to_oklab(color), + coverage: 1.0, + fill_tolerance: config.color_tolerance, + }) + } else { + detect_background_color(image, config) } +} - if total_border == 0 { - return None; +/// Compatibility wrapper: detect + mask + apply in one call (used by the +/// sprite-sheet path, which operates on standalone images). +pub fn remove_background( + image: &mut RgbaImage, + config: &BackgroundConfig, +) -> Result>> { + if !config.enabled { + return Ok(None); } + let Some(bg) = resolve_background(image, config) else { + info!("No dominant border color detected, skipping background removal"); + return Ok(None); + }; - // Find the most common border color - color_counts.sort_by(|a, b| b.1.cmp(&a.1)); - let (top_color, top_count) = color_counts[0]; - let coverage = top_count as f32 / total_border as f32; + let mask = build_bg_mask(image, &bg, config); + let removed = apply_bg_mask(image, &mask); + info!( + removed, + total = image.width() * image.height(), + r = bg.color[0], + g = bg.color[1], + b = bg.color[2], + "Background removal complete" + ); + Ok(Some(bg.color)) +} - if coverage >= threshold { - Some(Rgba(top_color)) - } else { - None +/// Check if a pixel matches the background color within tolerance. +pub fn is_bg_pixel(image: &RgbaImage, x: u32, y: u32, bg_oklab: Oklab, tolerance: f32) -> bool { + let pixel = *image.get_pixel(x, y); + if pixel[3] == 0 { + return false; // Already transparent } + oklab_distance(rgba_to_oklab(pixel), bg_oklab) <= tolerance +} + +/// Auto-detect the dominant border color (cluster-based). +/// +/// Compatibility shim for callers that only need a representative color. +pub fn detect_border_color(image: &RgbaImage, threshold: f32) -> Option> { + let config = BackgroundConfig { + border_threshold: threshold, + ..Default::default() + }; + detect_background_color(image, &config).map(|bg| bg.color) +} + +/// Iterator over the image's border pixels. +fn border_pixels(image: &RgbaImage) -> impl Iterator + '_ { + let width = image.width(); + let height = image.height(); + let top_bottom = (0..width).flat_map(move |x| [(x, 0), (x, height - 1)]); + let sides = (1..height.saturating_sub(1)).flat_map(move |y| [(0, y), (width - 1, y)]); + top_bottom + .chain(sides) + .map(move |(x, y)| image.get_pixel(x, y).0) +} + +/// Opaque histogram entries in deterministic order. +fn entries_opaque(hist: &ColorHistogram) -> Vec<([u8; 4], u32)> { + hist.sorted_entries() + .into_iter() + .filter(|&(c, _)| c[3] > 0) + .collect() } #[cfg(test)] @@ -241,11 +395,9 @@ mod tests { let blue = Rgba([0, 0, 255, 255]); let mut img = RgbaImage::new(8, 8); - // Fill with gray for pixel in img.pixels_mut() { *pixel = gray; } - // Interior colors for y in 2..6 { for x in 2..6 { img.put_pixel(x, y, if (x + y) % 2 == 0 { red } else { blue }); @@ -261,32 +413,69 @@ mod tests { assert_eq!(detected, Some(Rgba([128, 128, 128, 255]))); } + #[test] + fn test_detect_gradient_background() { + // Vertical gradient border (unique color per row): the exact + // histogram found nothing here; clustering must succeed. + let mut img = RgbaImage::new(12, 12); + for y in 0..12 { + let v = 200 + (y * 2) as u8; + for x in 0..12 { + img.put_pixel(x, y, Rgba([v, v, v, 255])); + } + } + // Sprite in the middle + for y in 4..8 { + for x in 4..8 { + img.put_pixel(x, y, Rgba([200, 30, 30, 255])); + } + } + let bg = detect_background_color(&img, &BackgroundConfig::default()) + .expect("gradient background must be detected"); + assert!(bg.coverage > 0.9, "coverage {}", bg.coverage); + } + #[test] fn test_flood_fill_removes_border() { let mut img = make_bordered_image(); let config = BackgroundConfig { enabled: true, - flood_fill: true, - border_threshold: 0.4, - color_tolerance: 0.05, ..Default::default() }; let result = remove_background(&mut img, &config).unwrap(); assert!(result.is_some()); - // Border pixels should be transparent assert_eq!(img.get_pixel(0, 0)[3], 0); assert_eq!(img.get_pixel(7, 7)[3], 0); assert_eq!(img.get_pixel(0, 3)[3], 0); - - // Interior non-gray pixels should still be opaque assert_ne!(img.get_pixel(3, 3)[3], 0); } + #[test] + fn test_interior_bg_survives_flood_fill() { + // A bg-colored region fully enclosed by the sprite must survive. + let gray = Rgba([128, 128, 128, 255]); + let red = Rgba([255, 0, 0, 255]); + let mut img = RgbaImage::from_pixel(9, 9, gray); + for y in 2..7 { + for x in 2..7 { + img.put_pixel(x, y, red); + } + } + img.put_pixel(4, 4, gray); // enclosed gray + + let config = BackgroundConfig { + enabled: true, + ..Default::default() + }; + remove_background(&mut img, &config).unwrap(); + assert_eq!(*img.get_pixel(4, 4), gray, "enclosed bg must survive"); + assert_eq!(img.get_pixel(0, 0)[3], 0); + } + #[test] fn test_global_removal() { - // Create image with gray scattered inside too let gray = Rgba([128, 128, 128, 255]); let red = Rgba([255, 0, 0, 255]); let mut img = RgbaImage::new(4, 4); @@ -294,21 +483,16 @@ mod tests { *pixel = gray; } img.put_pixel(1, 1, red); - img.put_pixel(2, 2, gray); // Interior gray pixel + img.put_pixel(2, 2, gray); let config = BackgroundConfig { enabled: true, - flood_fill: false, // Global mode - border_threshold: 0.4, - color_tolerance: 0.05, + flood_fill: false, ..Default::default() }; remove_background(&mut img, &config).unwrap(); - - // ALL gray pixels removed, including interior assert_eq!(img.get_pixel(2, 2)[3], 0); - // Red pixel preserved assert_eq!(*img.get_pixel(1, 1), red); } @@ -321,7 +505,6 @@ mod tests { enabled: true, bg_color: Some([50, 100, 150]), flood_fill: false, - color_tolerance: 0.05, ..Default::default() }; @@ -332,35 +515,96 @@ mod tests { #[test] fn test_no_dominant_border() { - // Every border pixel is a different color — no dominant bg - let mut img = RgbaImage::new(4, 4); - let mut val = 0u8; - for y in 0..4 { - for x in 0..4 { - img.put_pixel(x, y, Rgba([val, val.wrapping_add(50), val.wrapping_add(100), 255])); - val = val.wrapping_add(20); + // Border colors spread over a huge range — no cluster wins. + let mut img = RgbaImage::new(6, 6); + let mut val = 0u16; + for y in 0..6 { + for x in 0..6 { + img.put_pixel( + x, + y, + Rgba([ + (val * 37 % 256) as u8, + (val * 91 % 256) as u8, + (val * 151 % 256) as u8, + 255, + ]), + ); + val += 1; } } + assert!(detect_background_color(&img, &BackgroundConfig::default()).is_none()); + } - let config = BackgroundConfig { - enabled: true, - border_threshold: 0.4, - color_tolerance: 0.05, - ..Default::default() - }; + #[test] + fn test_chroma_mask_multiple_keys_global() { + // Magenta and green keys removed everywhere — including regions not + // connected to the border — while other colors survive. + let magenta = Rgba([255, 0, 255, 255]); + let green = Rgba([0, 255, 0, 255]); + let red = Rgba([200, 30, 30, 255]); + let mut img = RgbaImage::from_pixel(6, 6, red); + img.put_pixel(2, 2, magenta); // interior, not border-connected + img.put_pixel(3, 3, green); + img.put_pixel(0, 0, magenta); + + let mask = build_chroma_mask(&img, &[[255, 0, 255], [0, 255, 0]], 0.05, false); + assert!(mask[(2 * 6 + 2) as usize], "interior magenta keyed"); + assert!(mask[(3 * 6 + 3) as usize], "interior green keyed"); + assert!(mask[0], "border magenta keyed"); + assert!(!mask[1], "red must survive"); + + let mut cleared = img.clone(); + let removed = apply_bg_mask(&mut cleared, &mask); + assert_eq!(removed, 3); + assert_eq!(cleared.get_pixel(2, 2)[3], 0); + assert_eq!(*cleared.get_pixel(1, 1), red); + } - let result = remove_background(&mut img, &config).unwrap(); - assert!(result.is_none()); // No background detected + #[test] + fn test_chroma_tolerance_matches_near_colors() { + let near_key = Rgba([250, 5, 250, 255]); // close to pure magenta + let img = RgbaImage::from_pixel(2, 2, near_key); + let tight = build_chroma_mask(&img, &[[255, 0, 255]], 0.005, false); + let loose = build_chroma_mask(&img, &[[255, 0, 255]], 0.08, false); + assert!(!tight[0], "tight tolerance must not match"); + assert!(loose[0], "loose tolerance must match"); } #[test] - fn test_disabled() { - let mut img = RgbaImage::from_pixel(4, 4, Rgba([128, 128, 128, 255])); - let config = BackgroundConfig::default(); // enabled: false + fn test_defringe_absorbs_blend_pixels() { + // White bg | fringe (bg/sprite blend) | red sprite. The fringe + // column must join the mask. + let white = Rgba([255, 255, 255, 255]); + let blend = Rgba([255, 128, 128, 255]); + let red = Rgba([255, 0, 0, 255]); + let mut img = RgbaImage::new(7, 5); + for y in 0..5 { + for x in 0..7 { + img.put_pixel( + x, + y, + match x { + 0..=1 => white, + 2 => blend, + 3..=4 => red, + 5 => blend, + _ => white, + }, + ); + } + } - let result = remove_background(&mut img, &config).unwrap(); - assert!(result.is_none()); - // All pixels still opaque - assert_eq!(img.get_pixel(0, 0)[3], 255); + let config = BackgroundConfig { + enabled: true, + ..Default::default() + }; + let bg = resolve_background(&img, &config).unwrap(); + let mask = build_bg_mask(&img, &bg, &config); + for y in 0..5u32 { + assert!(mask[(y * 7 + 2) as usize], "left fringe not absorbed"); + assert!(mask[(y * 7 + 5) as usize], "right fringe not absorbed"); + assert!(!mask[(y * 7 + 3) as usize], "sprite must stay"); + } } } diff --git a/src/pipeline/dither.rs b/src/pipeline/dither.rs new file mode 100644 index 0000000..ca9c2d1 --- /dev/null +++ b/src/pipeline/dither.rs @@ -0,0 +1,373 @@ +//! Detection of ordered dithering (checkerboard and row/column +//! alternation) between pairs of block colors. +//! +//! Dithering is how pixel art fakes gradients and texture; AI generators +//! use it heavily and it usually looks better preserved. Detected pairs are +//! pinned as fixed k-means centroids so quantization cannot merge the two +//! partner colors and flatten the pattern. Preservation is deliberately not +//! at-all-costs: sparse specks can't reach the alternation floor, so noise +//! still collapses. + +use image::RgbaImage; +use palette::Oklab; + +use crate::color::oklab::{oklab_distance, rgba_to_oklab}; +use crate::pipeline::Grid; + +/// Configuration for dither-pair detection. +#[derive(Debug, Clone)] +pub struct DitherConfig { + /// Whether detection (and centroid pinning) runs at all. + pub enabled: bool, + /// OKLAB tolerance for grouping near-identical block colors before + /// counting alternation (absorbs residual per-block variance). + pub merge_tolerance: f32, + /// Absolute floor: a pair needs at least this many alternating blocks. + /// Stray pixels can never reach it. + pub min_alternating: u32, + /// The alternating blocks must also make up at least this fraction of + /// the pair's total footprint — two merely-adjacent solid regions don't + /// qualify, a real checkerboard trivially does. + pub min_alternating_frac: f32, + /// Keep at most this many pairs (strongest first). + pub max_pairs: usize, +} + +impl Default for DitherConfig { + fn default() -> Self { + Self { + enabled: true, + merge_tolerance: 0.03, + min_alternating: 8, + min_alternating_frac: 0.5, + max_pairs: 4, + } + } +} + +/// A detected dither pair. +#[derive(Debug, Clone)] +pub struct DitherPair { + pub a_rgb: [u8; 3], + pub b_rgb: [u8; 3], + pub a_oklab: Oklab, + pub b_oklab: Oklab, + /// Blocks participating in an alternating pattern between the two. + pub alternating: u32, + /// Total blocks carrying either color. + pub total: u32, +} + +/// Detect alternating two-color patterns on the logical block grid. +/// +/// `grid` is the pixel grid when the image is still at original resolution +/// (snap mode); pass `None` for an image already at logical resolution +/// (reduced modes), where every pixel is a block. +pub fn detect_dither_pairs( + image: &RgbaImage, + grid: Option<&Grid>, + config: &DitherConfig, +) -> Vec { + if !config.enabled { + return Vec::new(); + } + + // One sample per block (blocks are solid post-snap; the center pixel is + // representative). None = transparent block, which never participates. + let (blocks_w, blocks_h, samples) = sample_blocks(image, grid); + if blocks_w < 2 || blocks_h < 2 { + return Vec::new(); + } + + // Group block colors within merge_tolerance, deterministically + // (heaviest first). + let mut counts: std::collections::HashMap<[u8; 3], u32> = std::collections::HashMap::new(); + for rgb in samples.iter().flatten() { + *counts.entry(*rgb).or_insert(0) += 1; + } + let mut entries: Vec<([u8; 3], u32)> = counts.into_iter().collect(); + entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + + struct Group { + rep_rgb: [u8; 3], + oklab: Oklab, + weight: u32, + } + let mut groups: Vec = Vec::new(); + let mut color_group: std::collections::HashMap<[u8; 3], usize> = + std::collections::HashMap::new(); + for (rgb, count) in entries { + let ok = rgba_to_oklab(image::Rgba([rgb[0], rgb[1], rgb[2], 255])); + let gid = groups + .iter() + .position(|g| oklab_distance(g.oklab, ok) <= config.merge_tolerance); + match gid { + Some(g) => { + // Weighted centroid update; representative stays the + // heaviest member (insertion order is weight-sorted). + let total = groups[g].weight + count; + let w_old = groups[g].weight as f32 / total as f32; + let w_new = count as f32 / total as f32; + groups[g].oklab = Oklab::new( + groups[g].oklab.l * w_old + ok.l * w_new, + groups[g].oklab.a * w_old + ok.a * w_new, + groups[g].oklab.b * w_old + ok.b * w_new, + ); + groups[g].weight = total; + color_group.insert(rgb, g); + } + None => { + groups.push(Group { + rep_rgb: rgb, + oklab: ok, + weight: count, + }); + color_group.insert(rgb, groups.len() - 1); + } + } + } + + // Per-block group ids. + let gid_at = |bx: i64, by: i64| -> Option { + if bx < 0 || by < 0 || bx >= blocks_w as i64 || by >= blocks_h as i64 { + return None; + } + samples[(by as u32 * blocks_w + bx as u32) as usize] + .as_ref() + .and_then(|rgb| color_group.get(rgb).copied()) + }; + + // Count alternation per adjacent group pair. + let mut alt: std::collections::HashMap<(usize, usize), u32> = std::collections::HashMap::new(); + for by in 0..blocks_h as i64 { + for bx in 0..blocks_w as i64 { + let Some(a) = gid_at(bx, by) else { continue }; + let left = gid_at(bx - 1, by); + let right = gid_at(bx + 1, by); + let up = gid_at(bx, by - 1); + let down = gid_at(bx, by + 1); + + // The candidate partner: the most common different group among + // the 4-neighbors. + let mut partner_counts: [(usize, u32); 4] = [(usize::MAX, 0); 4]; + let mut n_partners = 0; + for g in [left, right, up, down].into_iter().flatten() { + if g == a { + continue; + } + let mut found = false; + for entry in partner_counts.iter_mut().take(n_partners) { + if entry.0 == g { + entry.1 += 1; + found = true; + break; + } + } + if !found { + partner_counts[n_partners] = (g, 1); + n_partners += 1; + } + } + let Some(&(b, _)) = partner_counts[..n_partners].iter().max_by_key(|&&(_, n)| n) else { + continue; + }; + + let in_b = |g: Option| g == Some(b); + let in_a = |g: Option| g == Some(a); + let v = in_b(up) as u32 + in_b(down) as u32; + let h = in_b(left) as u32 + in_b(right) as u32; + + // Checkerboard: at least 3 of 4 neighbors are the partner. + // Row alternation: both vertical partners, both horizontal same. + // Column alternation: the transpose. + let alternates = v + h >= 3 + || (v == 2 && in_a(left) && in_a(right)) + || (h == 2 && in_a(up) && in_a(down)); + + if alternates { + let key = (a.min(b), a.max(b)); + *alt.entry(key).or_insert(0) += 1; + } + } + } + + // Qualify and rank. + let mut pairs: Vec = alt + .into_iter() + .filter_map(|((a, b), count)| { + let footprint = groups[a].weight + groups[b].weight; + let floor = config + .min_alternating + .max((config.min_alternating_frac * footprint as f32) as u32); + if count < floor { + return None; + } + Some(DitherPair { + a_rgb: groups[a].rep_rgb, + b_rgb: groups[b].rep_rgb, + a_oklab: groups[a].oklab, + b_oklab: groups[b].oklab, + alternating: count, + total: footprint, + }) + }) + .collect(); + pairs.sort_by(|a, b| { + b.alternating + .cmp(&a.alternating) + .then(a.a_rgb.cmp(&b.a_rgb)) + }); + pairs.truncate(config.max_pairs); + pairs +} + +/// Sample one representative pixel per block. Returns (blocks_w, blocks_h, +/// row-major samples; None = transparent block). +fn sample_blocks(image: &RgbaImage, grid: Option<&Grid>) -> (u32, u32, Vec>) { + match grid { + Some(grid) => { + let edges_x = grid.edges_x(image.width()); + let edges_y = grid.edges_y(image.height()); + let bw = (edges_x.len() - 1) as u32; + let bh = (edges_y.len() - 1) as u32; + let mut samples = Vec::with_capacity((bw * bh) as usize); + for by in 0..bh as usize { + for bx in 0..bw as usize { + let cx = (edges_x[bx] + edges_x[bx + 1]) / 2; + let cy = (edges_y[by] + edges_y[by + 1]) / 2; + let p = image.get_pixel(cx, cy); + samples.push((p[3] >= 128).then(|| [p[0], p[1], p[2]])); + } + } + (bw, bh, samples) + } + None => { + let samples = image + .pixels() + .map(|p| (p[3] >= 128).then(|| [p[0], p[1], p[2]])) + .collect(); + (image.width(), image.height(), samples) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + const A: [u8; 3] = [100, 100, 100]; + const B: [u8; 3] = [160, 160, 160]; + + fn img_from(f: impl Fn(u32, u32) -> [u8; 3], w: u32, h: u32) -> RgbaImage { + let mut img = RgbaImage::new(w, h); + for y in 0..h { + for x in 0..w { + let c = f(x, y); + img.put_pixel(x, y, Rgba([c[0], c[1], c[2], 255])); + } + } + img + } + + fn detect(img: &RgbaImage) -> Vec { + detect_dither_pairs(img, None, &DitherConfig::default()) + } + + #[test] + fn test_checkerboard_detected() { + let img = img_from(|x, y| if (x + y) % 2 == 0 { A } else { B }, 8, 8); + let pairs = detect(&img); + assert_eq!(pairs.len(), 1, "one pair expected"); + let p = &pairs[0]; + let mut got = [p.a_rgb, p.b_rgb]; + got.sort(); + assert_eq!(got, [A, B]); + } + + #[test] + fn test_checkerboard_with_noise_merges() { + // Per-block ±2 RGB noise must still group into one pair. + let img = img_from( + |x, y| { + let base = if (x + y) % 2 == 0 { A } else { B }; + let n = ((x * 3 + y * 7) % 3) as u8; + [base[0] + n, base[1] + n, base[2] + n] + }, + 8, + 8, + ); + let pairs = detect(&img); + assert_eq!( + pairs.len(), + 1, + "noisy checkerboard should merge into one pair" + ); + } + + #[test] + fn test_row_alternation_detected() { + let img = img_from(|_, y| if y % 2 == 0 { A } else { B }, 8, 8); + assert_eq!(detect(&img).len(), 1); + } + + #[test] + fn test_column_alternation_detected() { + let img = img_from(|x, _| if x % 2 == 0 { A } else { B }, 8, 8); + assert_eq!(detect(&img).len(), 1); + } + + #[test] + fn test_solid_halves_not_a_pair() { + // Two solid regions sharing a border: only the seam alternates, + // far below half the footprint. + let img = img_from(|x, _| if x < 8 { A } else { B }, 16, 16); + assert!(detect(&img).is_empty(), "solid regions are not dither"); + } + + #[test] + fn test_sparse_specks_rejected() { + // A few isolated B pixels on an A field: below the absolute floor. + let img = img_from( + |x, y| { + if (x, y) == (2, 2) || (x, y) == (9, 5) || (x, y) == (5, 12) { + B + } else { + A + } + }, + 16, + 16, + ); + assert!(detect(&img).is_empty(), "specks are noise, not dither"); + } + + #[test] + fn test_snap_grid_sampling() { + // 4px blocks forming a checkerboard at original resolution. + let img = img_from( + |x, y| { + if ((x / 4) + (y / 4)) % 2 == 0 { + A + } else { + B + } + }, + 32, + 32, + ); + let grid = Grid::from_integer(4, (0, 0)); + let pairs = detect_dither_pairs(&img, Some(&grid), &DitherConfig::default()); + assert_eq!(pairs.len(), 1); + } + + #[test] + fn test_disabled_returns_nothing() { + let img = img_from(|x, y| if (x + y) % 2 == 0 { A } else { B }, 8, 8); + let config = DitherConfig { + enabled: false, + ..Default::default() + }; + assert!(detect_dither_pairs(&img, None, &config).is_empty()); + } +} diff --git a/src/pipeline/downscale.rs b/src/pipeline/downscale.rs index 4b238a5..a8d1d27 100644 --- a/src/pipeline/downscale.rs +++ b/src/pipeline/downscale.rs @@ -1,17 +1,29 @@ -use anyhow::{bail, Result}; +use crate::error::Result; use image::{Rgba, RgbaImage}; -use std::collections::HashMap; use tracing::info; -use crate::pipeline::PipelineState; +use crate::color::oklab::{oklab_distance, rgba_to_oklab}; +use crate::pipeline::{BlockShareMask, PipelineState}; + +/// Pixels below this alpha never vote on a block's color — their RGB is +/// blend residue or undefined data. +const ALPHA_VOTE_MIN: u8 = 8; + +/// Colors within this OKLAB distance are agglomerated into one vote +/// cluster (about one just-noticeable difference). AI upscales carry +/// per-pixel chroma noise that fragments exact-value votes; without +/// clustering, five identical fringe pixels can outvote a hundred +/// nearly-identical block pixels. +const MERGE_EPS: f32 = 0.02; /// Downscale strategy. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] pub enum DownscaleMode { /// Snap: clean up each block IN-PLACE without reducing resolution. - /// For each NxN block, finds 1-2 representative colors and snaps every - /// pixel to the nearest one. Preserves dithering, gradients, and lighting - /// patterns while aligning to the grid. Best for AI art. + /// For each block, finds the dominant color and snaps every pixel to it. + /// Preserves dithering, gradients, and lighting patterns while aligning + /// to the grid. Best for AI art. #[default] Snap, /// Center-weighted: pixels near the block center have more influence. @@ -38,7 +50,7 @@ impl std::fmt::Display for DownscaleMode { impl std::str::FromStr for DownscaleMode { type Err = String; - fn from_str(s: &str) -> Result { + fn from_str(s: &str) -> std::result::Result { match s.to_lowercase().as_str() { "snap" | "s" => Ok(DownscaleMode::Snap), "center-weighted" | "center_weighted" | "cw" => Ok(DownscaleMode::CenterWeighted), @@ -52,304 +64,454 @@ impl std::str::FromStr for DownscaleMode { } } +/// Configuration for the grid-normalization stage. +#[derive(Debug, Clone)] +pub struct DownscaleConfig { + pub mode: DownscaleMode, + /// Preserve original per-pixel alpha instead of binarizing each block + /// to fully opaque / fully transparent. + pub keep_alpha: bool, + /// Non-snap modes: emit the logical-resolution image directly instead + /// of the default crisp integer re-upscale. + pub logical_output: bool, +} + +impl Default for DownscaleConfig { + fn default() -> Self { + Self { + mode: DownscaleMode::Snap, + keep_alpha: false, + logical_output: false, + } + } +} + +/// A block's pixel span: `[x0, x1) × [y0, y1)`. +#[derive(Debug, Clone, Copy)] +struct Span { + x0: u32, + x1: u32, + y0: u32, + y1: u32, +} + +/// The outcome of voting on one block. +struct BlockVote { + /// Winning color (weighted medoid — an actual input pixel color), or + /// None when no pixel was eligible to vote. + color: Option<[u8; 3]>, + /// Weighted share of alpha coverage in [0, 1]. + opaque_share: f32, + /// The winning cluster's share of the color vote, in [0, 1]; 1.0 for + /// blocks with no vote (nothing to be uncertain about). + color_share: f32, +} + +/// Weighting scheme for block votes. +#[derive(Clone, Copy, PartialEq)] +enum WeightKind { + /// Quadratic falloff from the span center (snap, center-weighted). + Center, + /// Every pixel counts the same (majority-vote). + Uniform, +} + /// Normalize the image to the detected grid. /// -/// In `Snap` mode: works at original resolution — for each NxN block, -/// finds representative colors and snaps every pixel to the nearest one. -/// Preserves dithering, gradients, and lighting patterns. +/// In `Snap` mode: works at original resolution — for each block, finds the +/// dominant color and snaps every pixel to it. In other modes: reduces to +/// logical pixel resolution (1 color per block); the pipeline orchestrator +/// handles subsequent resizing. /// -/// In other modes: reduces to logical pixel resolution (1 color per block). -/// The pipeline orchestrator handles any subsequent resizing. -pub fn majority_vote_downscale( - state: &mut PipelineState, - mode: DownscaleMode, -) -> Result<()> { - let grid_size = state - .grid_size - .expect("majority_vote_downscale requires grid_size to be set"); - let (phase_x, phase_y) = state.grid_phase.unwrap_or((0, 0)); +/// Blocks come from [`Grid::edges_x`]/[`Grid::edges_y`], so margins and +/// trailing remainders are processed as clipped partial blocks. Alpha is +/// binarized per block by default (fully opaque or fully transparent by +/// weighted vote); `keep_alpha` preserves the original per-pixel alpha. +/// Per-block winning-vote shares are recorded in +/// `diagnostics.block_vote_shares` so low-confidence blocks are visible. +pub fn majority_vote_downscale(state: &mut PipelineState, config: &DownscaleConfig) -> Result<()> { + let grid = state + .grid + .expect("majority_vote_downscale requires a detected or overridden grid"); let image = &state.image; let width = image.width(); let height = image.height(); - if grid_size < 2 { - return Ok(()); // Nothing to do - } - - let blocks_w = (width - phase_x) / grid_size; - let blocks_h = (height - phase_y) / grid_size; - - if blocks_w == 0 || blocks_h == 0 { - bail!( - "Image {}x{} with grid size {} and phase ({},{}) produces no output pixels", - width, - height, - grid_size, - phase_x, - phase_y - ); - } + let edges_x = grid.edges_x(width); + let edges_y = grid.edges_y(height); + let blocks_w = (edges_x.len() - 1) as u32; + let blocks_h = (edges_y.len() - 1) as u32; - if mode == DownscaleMode::Snap { - info!( - width, - height, - blocks_w, - blocks_h, - grid_size, - phase_x, - phase_y, - mode = %mode, - "Snapping pixels to grid (preserving resolution)" - ); - state.image = snap_blocks(image, grid_size, phase_x, phase_y, blocks_w, blocks_h); - return Ok(()); - } + let mask = state.bg_mask.as_deref(); info!( - from_w = width, - from_h = height, - to_w = blocks_w, - to_h = blocks_h, - grid_size, - phase_x, - phase_y, - mode = %mode, - "Downscaling to logical pixels" + width, + height, + blocks_w, + blocks_h, + pitch_x = grid.pitch_x, + pitch_y = grid.pitch_y, + mode = %config.mode, + "Normalizing to grid" ); - let mut output = RgbaImage::new(blocks_w, blocks_h); + let mut shares = vec![1.0f32; (blocks_w * blocks_h) as usize]; + let mut output = if config.mode == DownscaleMode::Snap { + image.clone() + } else { + RgbaImage::new(blocks_w, blocks_h) + }; - for oy in 0..blocks_h { - for ox in 0..blocks_w { - let block_x = phase_x + ox * grid_size; - let block_y = phase_y + oy * grid_size; + for by in 0..blocks_h { + for bx in 0..blocks_w { + let span = Span { + x0: edges_x[bx as usize], + x1: edges_x[bx as usize + 1], + y0: edges_y[by as usize], + y1: edges_y[by as usize + 1], + }; - let color = match mode { - DownscaleMode::CenterWeighted => { - block_center_weighted(image, block_x, block_y, grid_size) + let vote = match config.mode { + DownscaleMode::Snap | DownscaleMode::CenterWeighted => { + vote_block(image, span, WeightKind::Center, mask, width) } DownscaleMode::MajorityVote => { - block_mode_color(image, block_x, block_y, grid_size) + vote_block(image, span, WeightKind::Uniform, mask, width) } - DownscaleMode::CenterPixel => { - block_center_pixel(image, block_x, block_y, grid_size) - } - DownscaleMode::Snap => unreachable!(), + DownscaleMode::CenterPixel => center_pixel_vote(image, span, mask, width), }; - output.put_pixel(ox, oy, color); + + shares[(by * blocks_w + bx) as usize] = if vote.opaque_share < 0.5 { + vote.opaque_share.max(1.0 - vote.opaque_share) + } else { + vote.color_share + }; + + match config.mode { + DownscaleMode::Snap => { + paint_snap_block(&mut output, image, span, &vote, config.keep_alpha) + } + _ => { + let pixel = logical_pixel(&vote, config.keep_alpha); + output.put_pixel(bx, by, pixel); + } + } } } + // Keep any background mask consistent with the output resolution: a + // block becomes all-masked iff most of its weight was masked. + if let Some(mask_vec) = state.bg_mask.take() { + state.bg_mask = Some(reblockify_mask( + &mask_vec, + width, + &edges_x, + &edges_y, + config.mode == DownscaleMode::Snap, + )); + } + + state.diagnostics.block_vote_shares = Some(BlockShareMask { + blocks_w, + blocks_h, + shares, + }); state.image = output; Ok(()) } -/// Snap mode: enforce a clean grid at original resolution. -/// -/// For each NxN block, find the dominant color (center-weighted majority vote) -/// and paint every pixel in that block that single color. This produces a -/// perfectly grid-aligned image with no mixels or stray pixels. +/// Quadratic center-falloff weight for a pixel at `(x, y)` within a span. +fn center_weight(span: Span, x: u32, y: u32) -> f32 { + let cx = (span.x0 + span.x1) as f32 / 2.0; + let cy = (span.y0 + span.y1) as f32 / 2.0; + let half_w = (span.x1 - span.x0) as f32 / 2.0; + let half_h = (span.y1 - span.y0) as f32 / 2.0; + let max_dist = (half_w * half_w + half_h * half_h).sqrt().max(f32::EPSILON); + let dist = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + (1.0 - (dist / max_dist).clamp(0.0, 1.0)).powi(2) +} + +/// Vote on a block's color and coverage. /// -/// Dithering between blocks is naturally preserved — adjacent blocks can have -/// different dominant colors, so checkerboard/gradient patterns across blocks -/// remain intact. -fn snap_blocks( +/// Color votes are bucketed by exact RGB, then greedily agglomerated in +/// OKLAB (within [`MERGE_EPS`]) so near-duplicate noise pools its weight. +/// The winner is the heaviest cluster; the emitted color is its weighted +/// medoid — always an actual input color, never an invented average. +/// Background-masked pixels and near-transparent pixels don't vote. +fn vote_block( image: &RgbaImage, - grid_size: u32, - phase_x: u32, - phase_y: u32, - blocks_w: u32, - blocks_h: u32, -) -> RgbaImage { - let width = image.width(); - let height = image.height(); - let mut output = image.clone(); - - let center = grid_size as f32 / 2.0; - let max_dist = center * std::f32::consts::SQRT_2; - - for by in 0..blocks_h { - for bx in 0..blocks_w { - let block_x = phase_x + bx * grid_size; - let block_y = phase_y + by * grid_size; - - // Find dominant color with center-weighted voting - let mut weighted_freq: Vec<([u8; 4], f32)> = Vec::new(); - - for dy in 0..grid_size { - for dx in 0..grid_size { - let px = block_x + dx; - let py = block_y + dy; - if px >= width || py >= height { - continue; - } - let pixel = image.get_pixel(px, py).0; - let dist = ((dx as f32 - center + 0.5).powi(2) - + (dy as f32 - center + 0.5).powi(2)) - .sqrt(); - let weight = (1.0 - (dist / max_dist).clamp(0.0, 1.0)).powi(2); - - if let Some(entry) = weighted_freq.iter_mut().find(|(c, _)| *c == pixel) { - entry.1 += weight; - } else { - weighted_freq.push((pixel, weight)); - } + span: Span, + kind: WeightKind, + mask: Option<&[bool]>, + img_width: u32, +) -> BlockVote { + // Exact-RGB buckets: (rgb, weight, oklab). + let mut buckets: Vec<([u8; 3], f32, palette::Oklab)> = Vec::new(); + let mut alpha_weight = 0.0f32; + let mut total_weight = 0.0f32; + + for py in span.y0..span.y1 { + for px in span.x0..span.x1 { + if let Some(m) = mask { + if m[(py * img_width + px) as usize] { + continue; // background pixels don't vote } } - - let dominant = match weighted_freq - .iter() - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) - { - Some(entry) => entry.0, - None => continue, + let pixel = image.get_pixel(px, py); + let w = match kind { + WeightKind::Center => center_weight(span, px, py), + WeightKind::Uniform => 1.0, }; + let a = pixel[3] as f32 / 255.0; + total_weight += w; + alpha_weight += w * a; - // Paint every pixel in the block with the dominant color - for dy in 0..grid_size { - for dx in 0..grid_size { - let px = block_x + dx; - let py = block_y + dy; - if px >= width || py >= height { - continue; - } - let alpha = image.get_pixel(px, py)[3]; - output.put_pixel(px, py, Rgba([dominant[0], dominant[1], dominant[2], alpha])); - } + if pixel[3] < ALPHA_VOTE_MIN { + continue; + } + let rgb = [pixel[0], pixel[1], pixel[2]]; + let cw = w * a; + if let Some(entry) = buckets.iter_mut().find(|(c, _, _)| *c == rgb) { + entry.1 += cw; + } else { + buckets.push((rgb, cw, rgba_to_oklab(*pixel))); } } } - output -} + if total_weight <= 0.0 || buckets.is_empty() { + return BlockVote { + color: None, + opaque_share: if total_weight <= 0.0 { + 0.0 + } else { + alpha_weight / total_weight + }, + color_share: 1.0, + }; + } -/// Center-weighted color selection. -/// -/// Each pixel in the block votes for its color, but pixels closer to the -/// center get higher weight. This preserves the AI's intended color -/// (typically cleanest at center) while being robust against edge noise. -fn block_center_weighted( - image: &RgbaImage, - block_x: u32, - block_y: u32, - grid_size: u32, -) -> Rgba { - let img_w = image.width(); - let img_h = image.height(); - let center = grid_size as f32 / 2.0; - let max_dist = center * std::f32::consts::SQRT_2; - - let mut weighted_freq: HashMap<[u8; 4], f32> = HashMap::new(); - - for dy in 0..grid_size { - for dx in 0..grid_size { - let px = block_x + dx; - let py = block_y + dy; - if px < img_w && py < img_h { - let pixel = image.get_pixel(px, py).0; - // Weight = 1.0 at center, quadratic falloff toward edges. - // Quadratic ensures center pixels strongly dominate over - // noisy edge pixels, which is critical for AI-generated art. - let dist = ((dx as f32 - center + 0.5).powi(2) - + (dy as f32 - center + 0.5).powi(2)) - .sqrt(); - let weight = (1.0 - (dist / max_dist).clamp(0.0, 1.0)).powi(2); - *weighted_freq.entry(pixel).or_insert(0.0) += weight; + let opaque_share = alpha_weight / total_weight; + + // Greedy OKLAB agglomeration over weight-sorted buckets. + buckets.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + struct Cluster { + weight: f32, + centroid: palette::Oklab, + members: Vec, + } + let mut clusters: Vec = Vec::new(); + for (i, &(_, w, ok)) in buckets.iter().enumerate() { + match clusters + .iter_mut() + .find(|c| oklab_distance(c.centroid, ok) < MERGE_EPS) + { + Some(c) => { + // Weighted centroid update + let total = c.weight + w; + c.centroid = palette::Oklab::new( + (c.centroid.l * c.weight + ok.l * w) / total, + (c.centroid.a * c.weight + ok.a * w) / total, + (c.centroid.b * c.weight + ok.b * w) / total, + ); + c.weight = total; + c.members.push(i); } + None => clusters.push(Cluster { + weight: w, + centroid: ok, + members: vec![i], + }), } } - if weighted_freq.is_empty() { - return Rgba([0, 0, 0, 0]); - } + let total_vote: f32 = clusters.iter().map(|c| c.weight).sum(); + let winner = clusters + .iter() + .max_by(|a, b| a.weight.total_cmp(&b.weight)) + .unwrap(); - // Pick the color with highest weighted vote - weighted_freq - .into_iter() - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) - .map(|(color, _)| Rgba(color)) - .unwrap_or(Rgba([0, 0, 0, 0])) + // Weighted medoid of the winner: the member color minimizing the + // weighted distance to the others. + let medoid = winner + .members + .iter() + .map(|&i| { + let cost: f32 = winner + .members + .iter() + .map(|&j| buckets[j].1 * oklab_distance(buckets[i].2, buckets[j].2)) + .sum(); + (i, cost) + }) + .min_by(|a, b| { + a.1.total_cmp(&b.1) + .then(buckets[b.0].1.total_cmp(&buckets[a.0].1)) + .then(a.0.cmp(&b.0)) + }) + .map(|(i, _)| buckets[i].0) + .unwrap(); + + BlockVote { + color: Some(medoid), + opaque_share, + color_share: winner.weight / total_vote.max(f32::EPSILON), + } } -/// Pick the center pixel of the block directly. -fn block_center_pixel( +/// CenterPixel mode: sample the block's (fractional) center directly, +/// falling back to a full vote when the center is masked or transparent. +fn center_pixel_vote( image: &RgbaImage, - block_x: u32, - block_y: u32, - grid_size: u32, -) -> Rgba { - let cx = block_x + grid_size / 2; - let cy = block_y + grid_size / 2; - if cx < image.width() && cy < image.height() { - *image.get_pixel(cx, cy) - } else { - Rgba([0, 0, 0, 0]) + span: Span, + mask: Option<&[bool]>, + img_width: u32, +) -> BlockVote { + let cx = (span.x0 + span.x1) / 2; + let cy = (span.y0 + span.y1) / 2; + let masked = mask + .map(|m| m[(cy * img_width + cx) as usize]) + .unwrap_or(false); + let pixel = *image.get_pixel(cx, cy); + if masked || pixel[3] < ALPHA_VOTE_MIN { + return vote_block(image, span, WeightKind::Center, mask, img_width); + } + BlockVote { + color: Some([pixel[0], pixel[1], pixel[2]]), + opaque_share: pixel[3] as f32 / 255.0, + color_share: 1.0, } } -/// Find the most common color in a block, with center-proximity tie-breaking. -fn block_mode_color( - image: &RgbaImage, - block_x: u32, - block_y: u32, - grid_size: u32, -) -> Rgba { - let mut freq: HashMap<[u8; 4], u32> = HashMap::new(); - let img_w = image.width(); - let img_h = image.height(); - let half = grid_size / 2; - - for dy in 0..grid_size { - for dx in 0..grid_size { - let px = block_x + dx; - let py = block_y + dy; - if px < img_w && py < img_h { - let pixel = image.get_pixel(px, py); - *freq.entry(pixel.0).or_insert(0) += 1; +/// Paint a snapped block into the output at original resolution. +fn paint_snap_block( + output: &mut RgbaImage, + original: &RgbaImage, + span: Span, + vote: &BlockVote, + keep_alpha: bool, +) { + if keep_alpha { + // Color from the vote, per-pixel alpha preserved. Blocks with no + // eligible votes stay untouched. + if let Some(rgb) = vote.color { + for py in span.y0..span.y1 { + for px in span.x0..span.x1 { + let alpha = original.get_pixel(px, py)[3]; + output.put_pixel(px, py, Rgba([rgb[0], rgb[1], rgb[2], alpha])); + } } } + return; } - if freq.is_empty() { - return Rgba([0, 0, 0, 0]); + // Binarized: the whole block is either the winning color at full + // opacity or fully transparent. + let pixel = match (vote.opaque_share >= 0.5, vote.color) { + (true, Some(rgb)) => Rgba([rgb[0], rgb[1], rgb[2], 255]), + _ => Rgba([0, 0, 0, 0]), + }; + for py in span.y0..span.y1 { + for px in span.x0..span.x1 { + output.put_pixel(px, py, pixel); + } } +} - let max_count = *freq.values().max().unwrap(); +/// Produce the single logical pixel for a block in reduced-resolution modes. +fn logical_pixel(vote: &BlockVote, keep_alpha: bool) -> Rgba { + let alpha = if keep_alpha { + // Weighted mean alpha (per-pixel alpha has no meaning at reduced + // resolution). + (vote.opaque_share * 255.0).round() as u8 + } else if vote.opaque_share >= 0.5 { + 255 + } else { + 0 + }; + match vote.color { + Some(rgb) if alpha > 0 => Rgba([rgb[0], rgb[1], rgb[2], alpha]), + _ => Rgba([0, 0, 0, 0]), + } +} - // Collect all colors tied for the maximum - let tied: Vec<[u8; 4]> = freq - .iter() - .filter(|(_, &count)| count == max_count) - .map(|(&color, _)| color) - .collect(); +/// Re-blockify a background mask after grid normalization: a block is +/// all-masked iff more than half its pixels were masked. +fn reblockify_mask( + mask: &[bool], + img_width: u32, + edges_x: &[u32], + edges_y: &[u32], + full_resolution: bool, +) -> Vec { + let blocks_w = edges_x.len() - 1; + let blocks_h = edges_y.len() - 1; + let mut block_masked = vec![false; blocks_w * blocks_h]; - if tied.len() == 1 { - return Rgba(tied[0]); + for by in 0..blocks_h { + for bx in 0..blocks_w { + let (x0, x1) = (edges_x[bx], edges_x[bx + 1]); + let (y0, y1) = (edges_y[by], edges_y[by + 1]); + let mut masked = 0u32; + let mut total = 0u32; + for py in y0..y1 { + for px in x0..x1 { + total += 1; + if mask[(py * img_width + px) as usize] { + masked += 1; + } + } + } + block_masked[by * blocks_w + bx] = total > 0 && masked * 2 > total; + } } - // Tie-break: prefer the color of the pixel closest to block center - let center_x = block_x + half; - let center_y = block_y + half; - if center_x < img_w && center_y < img_h { - let center_pixel = image.get_pixel(center_x, center_y).0; - if tied.contains(¢er_pixel) { - return Rgba(center_pixel); - } + if !full_resolution { + return block_masked; } - // Fallback: just take the first tied color - Rgba(tied[0]) + // Snap mode keeps original resolution: expand back to pixels. + let width = *edges_x.last().unwrap() as usize; + let height = *edges_y.last().unwrap() as usize; + let mut out = vec![false; width * height]; + for by in 0..blocks_h { + for bx in 0..blocks_w { + if block_masked[by * blocks_w + bx] { + for py in edges_y[by]..edges_y[by + 1] { + for px in edges_x[bx]..edges_x[bx + 1] { + out[py as usize * width + px as usize] = true; + } + } + } + } + } + out } #[cfg(test)] mod tests { use super::*; - use crate::pipeline::PipelineState; + use crate::pipeline::{Grid, PipelineState}; + + fn state_with_grid(img: RgbaImage, size: u32, phase: (u32, u32)) -> PipelineState { + let mut state = PipelineState::new(img); + state.grid = Some(Grid::from_integer(size, phase)); + state + } + + fn run(state: &mut PipelineState, mode: DownscaleMode) { + majority_vote_downscale( + state, + &DownscaleConfig { + mode, + ..Default::default() + }, + ) + .unwrap(); + } #[test] fn test_majority_vote_uniform_blocks() { - // 8x8 image with 4 quadrants of solid color, grid size 4 let mut img = RgbaImage::new(8, 8); let red = Rgba([255, 0, 0, 255]); let green = Rgba([0, 255, 0, 255]); @@ -360,8 +522,6 @@ mod tests { for x in 0..4 { img.put_pixel(x, y, red); } - } - for y in 0..4 { for x in 4..8 { img.put_pixel(x, y, green); } @@ -370,18 +530,13 @@ mod tests { for x in 0..4 { img.put_pixel(x, y, blue); } - } - for y in 4..8 { for x in 4..8 { img.put_pixel(x, y, yellow); } } - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); - - majority_vote_downscale(&mut state, DownscaleMode::MajorityVote).unwrap(); + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::MajorityVote); assert_eq!(state.image.width(), 2); assert_eq!(state.image.height(), 2); @@ -393,7 +548,6 @@ mod tests { #[test] fn test_majority_vote_noisy_block() { - // 4x4 image: 12 red pixels, 4 blue pixels. Grid size 4 → 1x1 output. let mut img = RgbaImage::new(4, 4); let red = Rgba([255, 0, 0, 255]); let blue = Rgba([0, 0, 255, 255]); @@ -401,95 +555,145 @@ mod tests { for pixel in img.pixels_mut() { *pixel = red; } - // Introduce some blue noise img.put_pixel(0, 0, blue); img.put_pixel(1, 0, blue); img.put_pixel(0, 1, blue); img.put_pixel(3, 3, blue); - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); - - majority_vote_downscale(&mut state, DownscaleMode::MajorityVote).unwrap(); + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::MajorityVote); - assert_eq!(state.image.width(), 1); - assert_eq!(state.image.height(), 1); - // Red is the majority (12 vs 4) assert_eq!(*state.image.get_pixel(0, 0), red); } #[test] - fn test_majority_vote_with_phase() { - // 9x9 image: 1px border of gray, then 2x2 grid of 4x4 colored blocks - let mut img = RgbaImage::new(9, 9); - let gray = Rgba([128, 128, 128, 255]); - let red = Rgba([255, 0, 0, 255]); - let green = Rgba([0, 255, 0, 255]); - let blue = Rgba([0, 0, 255, 255]); - let yellow = Rgba([255, 255, 0, 255]); - - for pixel in img.pixels_mut() { - *pixel = gray; - } - for y in 1..5 { - for x in 1..5 { - img.put_pixel(x, y, red); + fn test_agglomerated_vote_beats_exact_key() { + // 5 near-identical reds (each a unique exact value) vs 4 identical + // blues: exact-key voting picks blue, OKLAB agglomeration must pool + // the reds and pick a red — and the output must be one of the + // actual red pixel values. + let reds = [ + [200u8, 10, 10], + [201, 11, 10], + [199, 10, 11], + [200, 12, 9], + [201, 9, 11], + ]; + let blue = [10u8, 10, 200]; + let mut img = RgbaImage::new(3, 3); + // 3x3 block: 5 reds, 4 blues + let mut i = 0; + for y in 0..3 { + for x in 0..3 { + let c = if (x + y) % 2 == 0 { + let c = reds[i % 5]; + i += 1; + c + } else { + blue + }; + img.put_pixel(x, y, Rgba([c[0], c[1], c[2], 255])); } } - for y in 1..5 { - for x in 5..9 { - img.put_pixel(x, y, green); + + let mut state = state_with_grid(img, 3, (0, 0)); + run(&mut state, DownscaleMode::MajorityVote); + + let out = *state.image.get_pixel(0, 0); + assert!( + reds.contains(&[out[0], out[1], out[2]]), + "expected an actual red, got {:?}", + out + ); + } + + #[test] + fn test_transparent_pixels_do_not_vote_color() { + // Block: 60% transparent (RGB garbage zeros), 40% red. The old code + // voted (0,0,0,0) dominant and painted black; now the block must + // binarize to... 40% opaque -> fully transparent. + let red = Rgba([255, 0, 0, 255]); + let clear = Rgba([0, 0, 0, 0]); + let mut img = RgbaImage::new(4, 4); + for y in 0..4 { + for x in 0..4 { + // 10 clear, 6 red + img.put_pixel( + x, + y, + if y < 2 || (y == 2 && x < 2) { + clear + } else { + red + }, + ); } } - for y in 5..9 { - for x in 1..5 { - img.put_pixel(x, y, blue); - } + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::Snap); + for p in state.image.pixels() { + assert_eq!(p[3], 0, "under-half coverage must binarize transparent"); } - for y in 5..9 { - for x in 5..9 { - img.put_pixel(x, y, yellow); + + // And the inverse: 60% red -> fully opaque red, no black fringe. + let mut img = RgbaImage::new(4, 4); + for y in 0..4 { + for x in 0..4 { + img.put_pixel( + x, + y, + if y < 2 || (y == 2 && x < 2) { + red + } else { + clear + }, + ); } } + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::Snap); + for p in state.image.pixels() { + assert_eq!(*p, red, "over-half coverage must binarize to solid color"); + } + } - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((1, 1)); - - majority_vote_downscale(&mut state, DownscaleMode::MajorityVote).unwrap(); - - assert_eq!(state.image.width(), 2); - assert_eq!(state.image.height(), 2); - assert_eq!(*state.image.get_pixel(0, 0), red); - assert_eq!(*state.image.get_pixel(1, 0), green); - assert_eq!(*state.image.get_pixel(0, 1), blue); - assert_eq!(*state.image.get_pixel(1, 1), yellow); + #[test] + fn test_keep_alpha_preserves_per_pixel_alpha() { + let red = Rgba([255, 0, 0, 255]); + let semi = Rgba([255, 0, 0, 100]); + let mut img = RgbaImage::from_pixel(4, 4, red); + img.put_pixel(1, 1, semi); + + let mut state = state_with_grid(img, 4, (0, 0)); + majority_vote_downscale( + &mut state, + &DownscaleConfig { + mode: DownscaleMode::Snap, + keep_alpha: true, + logical_output: false, + }, + ) + .unwrap(); + + assert_eq!(state.image.get_pixel(1, 1)[3], 100); + assert_eq!(state.image.get_pixel(0, 0)[3], 255); } #[test] fn test_center_weighted_prefers_center() { - // 4x4 block: mostly blue at edges, red at center let red = Rgba([255, 0, 0, 255]); let blue = Rgba([0, 0, 255, 255]); let mut img = RgbaImage::new(4, 4); - - // Fill with blue for pixel in img.pixels_mut() { *pixel = blue; } - // Center 2x2 is red img.put_pixel(1, 1, red); img.put_pixel(2, 1, red); img.put_pixel(1, 2, red); img.put_pixel(2, 2, red); - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); - - // Center-weighted should prefer red (center pixels have higher weight) - majority_vote_downscale(&mut state, DownscaleMode::CenterWeighted).unwrap(); + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::CenterWeighted); assert_eq!(*state.image.get_pixel(0, 0), red); } @@ -498,97 +702,143 @@ mod tests { let red = Rgba([255, 0, 0, 255]); let blue = Rgba([0, 0, 255, 255]); let mut img = RgbaImage::new(4, 4); - - // Fill with blue, center pixel is red for pixel in img.pixels_mut() { *pixel = blue; } img.put_pixel(2, 2, red); - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); - - majority_vote_downscale(&mut state, DownscaleMode::CenterPixel).unwrap(); + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::CenterPixel); assert_eq!(*state.image.get_pixel(0, 0), red); } #[test] - fn test_snap_uniform_block() { - // Single 4x4 block: mostly red with some noise → should become all red + fn test_snap_dithering_across_blocks() { let red = Rgba([255, 0, 0, 255]); + let blue = Rgba([0, 0, 255, 255]); let noise = Rgba([128, 0, 128, 255]); - let mut img = RgbaImage::from_pixel(4, 4, red); - img.put_pixel(0, 0, noise); - img.put_pixel(3, 3, noise); + let mut img = RgbaImage::new(8, 4); + for y in 0..4 { + for x in 0..4 { + img.put_pixel(x, y, red); + } + for x in 4..8 { + img.put_pixel(x, y, blue); + } + } + img.put_pixel(3, 1, noise); + img.put_pixel(4, 1, noise); - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::Snap); + + assert_eq!(*state.image.get_pixel(0, 0), red); + assert_eq!(*state.image.get_pixel(4, 0), blue); + assert_eq!(*state.image.get_pixel(3, 1), red); + assert_eq!(*state.image.get_pixel(4, 1), blue); + } - majority_vote_downscale(&mut state, DownscaleMode::Snap).unwrap(); + #[test] + fn test_snap_preserves_resolution_and_processes_margins() { + let red = Rgba([255, 0, 0, 255]); + let noise = Rgba([10, 20, 30, 255]); + let mut img = RgbaImage::from_pixel(10, 4, red); + img.put_pixel(0, 1, noise); + img.put_pixel(9, 2, noise); - assert_eq!(state.image.width(), 4); - assert_eq!(state.image.height(), 4); + let mut state = state_with_grid(img, 4, (1, 0)); + run(&mut state, DownscaleMode::Snap); - // Every pixel should be the dominant color (red) - for pixel in state.image.pixels() { - assert_eq!(*pixel, red, "All pixels in block should be dominant color"); - } + assert_eq!(state.image.width(), 10); + assert_eq!(*state.image.get_pixel(0, 1), red); + assert_eq!(*state.image.get_pixel(9, 2), red); } #[test] - fn test_snap_dithering_across_blocks() { - // 8x4 image: two 4x4 blocks — left=red, right=blue - // This simulates dithering across blocks (alternating block colors) + fn test_block_share_mask_flags_uncertain_blocks() { + // Left block uniform red (share 1.0); right block 50/50 red/blue + // (share ~0.5, below the LOW_VOTE_SHARE threshold). let red = Rgba([255, 0, 0, 255]); let blue = Rgba([0, 0, 255, 255]); - let noise = Rgba([128, 0, 128, 255]); - let mut img = RgbaImage::new(8, 4); for y in 0..4 { for x in 0..4 { img.put_pixel(x, y, red); } for x in 4..8 { - img.put_pixel(x, y, blue); + img.put_pixel(x, y, if (x + y) % 2 == 0 { red } else { blue }); } } - // Add noise at the boundary - img.put_pixel(3, 1, noise); - img.put_pixel(4, 1, noise); - let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); + let mut state = state_with_grid(img, 4, (0, 0)); + run(&mut state, DownscaleMode::Snap); - majority_vote_downscale(&mut state, DownscaleMode::Snap).unwrap(); + let mask = state.diagnostics.block_vote_shares.as_ref().unwrap(); + assert_eq!((mask.blocks_w, mask.blocks_h), (2, 1)); + assert!( + mask.shares[0] > 0.9, + "uniform block share {}", + mask.shares[0] + ); + assert!( + mask.shares[1] < crate::pipeline::LOW_VOTE_SHARE, + "contested block share {}", + mask.shares[1] + ); + } - // Both colors should survive (in different blocks) - let left = *state.image.get_pixel(0, 0); - let right = *state.image.get_pixel(4, 0); - assert_eq!(left, red); - assert_eq!(right, blue); + #[test] + fn test_masked_pixels_excluded_from_vote() { + // A block where the masked majority is blue and the unmasked + // minority is red: with the mask applied, red must win. + let red = Rgba([255, 0, 0, 255]); + let blue = Rgba([0, 0, 255, 255]); + let mut img = RgbaImage::new(4, 4); + let mut mask = vec![false; 16]; + for y in 0..4u32 { + for x in 0..4u32 { + if y < 3 { + img.put_pixel(x, y, blue); + mask[(y * 4 + x) as usize] = true; + } else { + img.put_pixel(x, y, red); + } + } + } - // Noise pixels should be cleaned up to their block's dominant color - assert_eq!(*state.image.get_pixel(3, 1), red); - assert_eq!(*state.image.get_pixel(4, 1), blue); + let mut state = state_with_grid(img, 4, (0, 0)); + state.bg_mask = Some(mask); + run(&mut state, DownscaleMode::Snap); + + // The block's unmasked pixels are all red and cover 25% of the + // block... but coverage is computed over unmasked weight only, so + // the block is fully opaque red. + assert_eq!(*state.image.get_pixel(0, 3), red); + // Mask was re-blockified: majority masked -> whole block masked. + let m = state.bg_mask.as_ref().unwrap(); + assert!(m.iter().all(|&b| b), "block should be fully masked"); } #[test] - fn test_snap_preserves_resolution() { - // Solid block — snap shouldn't change dimensions + fn test_fractional_pitch_snap() { let red = Rgba([255, 0, 0, 255]); - let img = RgbaImage::from_pixel(8, 8, red); + let blue = Rgba([0, 0, 255, 255]); + let mut img = RgbaImage::new(32, 4); + for y in 0..4 { + for x in 0..32 { + let block = ((x as f32) / (32.0 / 3.0)) as u32; + img.put_pixel(x, y, if block.is_multiple_of(2) { red } else { blue }); + } + } let mut state = PipelineState::new(img); - state.grid_size = Some(4); - state.grid_phase = Some((0, 0)); - - majority_vote_downscale(&mut state, DownscaleMode::Snap).unwrap(); + state.grid = Some(Grid::new(32.0 / 3.0, 4.0, 0.0, 0.0)); + run(&mut state, DownscaleMode::Snap); - assert_eq!(state.image.width(), 8); - assert_eq!(state.image.height(), 8); + assert_eq!(state.image.width(), 32); + assert_eq!(*state.image.get_pixel(0, 0), red); + assert_eq!(*state.image.get_pixel(15, 0), blue); + assert_eq!(*state.image.get_pixel(31, 0), red); } } diff --git a/src/pipeline/grid.rs b/src/pipeline/grid.rs new file mode 100644 index 0000000..34dbaeb --- /dev/null +++ b/src/pipeline/grid.rs @@ -0,0 +1,255 @@ +//! The detected (or overridden) pixel grid: fractional pitch and phase. +//! +//! AI upscales frequently render a sprite at a non-integer pitch (a 48-cell +//! sprite at 512px is 10.667px per logical pixel), so pitch and phase are +//! carried as `f32` per axis. All pixel-coordinate math goes through +//! [`Grid::edges_x`]/[`Grid::edges_y`], which produce clamped, monotone block +//! boundaries — margins and trailing remainders become ordinary clipped +//! partial blocks, and no caller ever subtracts a phase from a width again. + +use crate::error::{NormalizeError, Result}; + +/// A pixel grid: block `i` on an axis spans +/// `[round(phase + i*pitch), round(phase + (i+1)*pitch))`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Grid { + /// Pixels per logical pixel, horizontally. Always >= 2. + pub pitch_x: f32, + /// Pixels per logical pixel, vertically. Always >= 2. + pub pitch_y: f32, + /// Canonical phase in `(-pitch_x, 0.0]`: block 0 starts at or before + /// x = 0, so a left margin is just a clipped partial block. + pub phase_x: f32, + /// Canonical phase in `(-pitch_y, 0.0]`. + pub phase_y: f32, +} + +/// Fold a phase into the canonical range `(-pitch, 0.0]`. +fn canonical_phase(phase: f32, pitch: f32) -> f32 { + let m = phase % pitch; + if m > 0.0 { + m - pitch + } else { + m + } +} + +impl Grid { + /// Build a grid from a fractional pitch and an integer phase offset. + pub fn new(pitch_x: f32, pitch_y: f32, phase_x: f32, phase_y: f32) -> Self { + Self { + pitch_x, + pitch_y, + phase_x: canonical_phase(phase_x, pitch_x), + phase_y: canonical_phase(phase_y, pitch_y), + } + } + + /// Build a square-pitch grid from an integer size and phase (the manual + /// override and legacy-detection path). + pub fn from_integer(size: u32, phase: (u32, u32)) -> Self { + Self::new(size as f32, size as f32, phase.0 as f32, phase.1 as f32) + } + + /// Build a square-pitch grid from a possibly fractional size. + pub fn from_size_phase(size: f32, phase: (u32, u32)) -> Self { + Self::new(size, size, phase.0 as f32, phase.1 as f32) + } + + /// Validate pitch against the image dimensions. + pub fn validate(&self, width: u32, height: u32) -> Result<()> { + for &(pitch, dim) in &[(self.pitch_x, width), (self.pitch_y, height)] { + if !pitch.is_finite() || pitch < 2.0 { + return Err(NormalizeError::InvalidGridSize(pitch)); + } + if pitch > dim as f32 { + return Err(NormalizeError::ImageTooSmall { + width, + height, + grid_size: pitch, + }); + } + } + Ok(()) + } + + /// Phase folded into `[0, pitch)` for display and reporting. + pub fn display_phase(&self) -> (f32, f32) { + let fold = |phase: f32, pitch: f32| { + let p = phase % pitch; + if p < 0.0 { + p + pitch + } else { + p + } + }; + ( + fold(self.phase_x, self.pitch_x), + fold(self.phase_y, self.pitch_y), + ) + } + + /// Block boundaries along x: a monotone Vec starting at 0 and ending at + /// `width`, one entry per edge (so `len() - 1` blocks). Empty spans from + /// clamping are dropped. + pub fn edges_x(&self, width: u32) -> Vec { + edges(self.phase_x, self.pitch_x, width) + } + + /// Block boundaries along y. See [`Grid::edges_x`]. + pub fn edges_y(&self, height: u32) -> Vec { + edges(self.phase_y, self.pitch_y, height) + } + + /// Logical dimensions of the image under this grid. + pub fn logical_size(&self, width: u32, height: u32) -> (u32, u32) { + ( + (self.edges_x(width).len() - 1) as u32, + (self.edges_y(height).len() - 1) as u32, + ) + } + + /// Integer upscale factors for re-upscaling a logical image: rounded + /// pitch per axis. + pub fn upscale_factors(&self) -> (u32, u32) { + ( + (self.pitch_x.round() as u32).max(1), + (self.pitch_y.round() as u32).max(1), + ) + } + + /// Rebase the grid to a coordinate system whose origin sits at + /// `(dx, dy)` in the current one (e.g. a sprite-sheet tile at that + /// offset): `phase' = phase - d`, re-canonicalized. + pub fn rebase(&self, dx: i32, dy: i32) -> Grid { + Grid::new( + self.pitch_x, + self.pitch_y, + self.phase_x - dx as f32, + self.phase_y - dy as f32, + ) + } + + /// True when pitch and phase are exact integers (clean upscales). + pub fn is_integer(&self) -> bool { + self.pitch_x.fract() == 0.0 + && self.pitch_y.fract() == 0.0 + && self.phase_x.fract() == 0.0 + && self.phase_y.fract() == 0.0 + } +} + +/// Compute clamped block edges along one axis. +fn edges(phase: f32, pitch: f32, len: u32) -> Vec { + debug_assert!(pitch >= 1.0); + let mut result = vec![0u32]; + if len == 0 { + return result; + } + // f64 so long axes with fractional pitch don't accumulate rounding error. + let phase = phase as f64; + let pitch = pitch as f64; + let mut i: u64 = 1; + loop { + let e = (phase + i as f64 * pitch).round(); + i += 1; + if e <= 0.0 { + continue; + } + let e = (e as u64).min(len as u64) as u32; + if e > *result.last().unwrap() { + result.push(e); + } + if e >= len { + break; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_grid_edges() { + let g = Grid::from_integer(4, (0, 0)); + assert_eq!(g.edges_x(8), vec![0, 4, 8]); + assert_eq!(g.logical_size(8, 8), (2, 2)); + assert!(g.is_integer()); + } + + #[test] + fn phase_creates_partial_blocks() { + // 9px axis, pitch 4, phase 1: margin block [0,1), then [1,5), [5,9). + let g = Grid::from_integer(4, (1, 1)); + assert_eq!(g.edges_x(9), vec![0, 1, 5, 9]); + assert_eq!(g.logical_size(9, 9), (3, 3)); + assert_eq!(g.display_phase(), (1.0, 1.0)); + } + + #[test] + fn trailing_partial_block_is_kept() { + // 10px axis, pitch 4, phase 0: [0,4), [4,8), [8,10). + let g = Grid::from_integer(4, (0, 0)); + assert_eq!(g.edges_x(10), vec![0, 4, 8, 10]); + } + + #[test] + fn fractional_pitch_edges() { + // 48 cells over 512px: pitch 10.666… + let g = Grid::new(512.0 / 48.0, 512.0 / 48.0, 0.0, 0.0); + let edges = g.edges_x(512); + assert_eq!(edges.len() - 1, 48); + assert_eq!(*edges.last().unwrap(), 512); + // Blocks alternate 11/10 px wide, never drifting more than 1px. + for w in edges.windows(2) { + let span = w[1] - w[0]; + assert!((10..=11).contains(&span), "span {} out of range", span); + } + } + + #[test] + fn rebase_shifts_phase() { + let g = Grid::from_integer(4, (0, 0)); + let t = g.rebase(6, 6); + // Origin at 6: old boundary at 8 is 2 in tile coords. + assert_eq!(t.edges_x(8), vec![0, 2, 6, 8]); + assert_eq!(t.display_phase(), (2.0, 2.0)); + } + + #[test] + fn rebase_roundtrip_phase() { + let g = Grid::new(10.667, 10.667, -3.2, -3.2); + let t = g.rebase(21, 0); + // Rebase keeps boundaries aligned: boundary positions in tile space + + // 21 must land on (rounded) original boundaries. + let orig: Vec = g.edges_x(200); + let tile_edges = t.edges_x(100); + // Interior edges only: first and last are clamps to the tile borders. + for e in &tile_edges[1..tile_edges.len() - 1] { + let back = e + 21; + assert!( + orig.iter().any(|&o| (o as i64 - back as i64).abs() <= 1), + "edge {} (orig {}) misaligned", + e, + back + ); + } + } + + #[test] + fn validate_rejects_bad_pitch() { + assert!(Grid::new(1.0, 4.0, 0.0, 0.0).validate(64, 64).is_err()); + assert!(Grid::new(4.0, 4.0, 0.0, 0.0).validate(64, 64).is_ok()); + assert!(Grid::new(128.0, 128.0, 0.0, 0.0).validate(64, 64).is_err()); + } + + #[test] + fn canonical_phase_range() { + let g = Grid::new(4.0, 4.0, 7.0, -9.0); + assert!(g.phase_x <= 0.0 && g.phase_x > -4.0); + assert!(g.phase_y <= 0.0 && g.phase_y > -4.0); + assert_eq!(g.display_phase(), (3.0, 3.0)); + } +} diff --git a/src/pipeline/grid_detect.rs b/src/pipeline/grid_detect.rs index ca90582..f407523 100644 --- a/src/pipeline/grid_detect.rs +++ b/src/pipeline/grid_detect.rs @@ -1,312 +1,652 @@ -use anyhow::Result; +//! Grid detection: estimate the pixel grid's pitch (possibly fractional) and +//! phase from gradient profiles. +//! +//! The image is first reduced to two 1-D profiles — for every adjacent pixel +//! pair, an alpha-aware OKLAB gradient energy is accumulated per column and +//! per row boundary. Every (pitch, phase) candidate is then scored against +//! those profiles in O(len/pitch), which makes an exhaustive fractional +//! search affordable: AI upscales routinely use non-integer pitch (a 48-cell +//! sprite at 512px is 10.667px per cell), which integer-only detection can +//! never find. +//! +//! Scoring: each comb boundary claims a fixed one- or two-index window, and +//! the score is the captured energy fraction minus the covered weight +//! fraction — an energy-concentration measure that is 0 for noise and for +//! vacuous dense combs, so no candidate can win by coverage alone. +//! Harmonics (2x pitch) miss half the true edges and score poorly; +//! sub-pitches (pitch/2) capture the same energy at double the coverage and +//! score lower, with an explicit refined comparison against integer +//! multiples as a safety net. + +use crate::error::Result; +use crate::parallel::*; use palette::Oklab; -use rayon::prelude::*; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use crate::color::oklab::{oklab_distance_sq, rgba_to_oklab}; -use crate::pipeline::{GridDetectConfig, PipelineState}; - -/// Result of grid detection for a single candidate. -#[derive(Debug, Clone)] -struct CandidateResult { - grid_size: u32, - phase_x: u32, - phase_y: u32, - /// Edge alignment score: ratio of mean gradient at grid boundaries to mean - /// gradient at non-boundary positions. Higher = better grid alignment. - score: f32, +use crate::pipeline::{Grid, GridDetectConfig, PipelineState}; + +/// Phase-scan step during the coarse pass (px). +const PHASE_STEP: f64 = 0.5; +/// Phase-scan step during refinement (px). +const PHASE_STEP_FINE: f64 = 0.1; +/// Tie threshold for the harmonic rule: candidates scoring within this factor +/// of the maximum form the tie set, and the largest pitch in it wins. +const TIE_FACTOR: f64 = 1.15; +/// The nearest integer pitch wins over a refined fractional estimate when it +/// scores at least this fraction of the refined score, keeping clean +/// upscales bit-identical. +const INT_SNAP_TOLERANCE: f64 = 0.97; +/// Phase closer to an integer than this rounds to it. +const INT_SNAP_PHASE: f64 = 0.25; + +/// Resolve a manual grid override from the config, if any. +/// +/// `override_grid` (programmatic, e.g. per sprite-sheet tile) wins over +/// `override_size`/`override_phase`. Phase defaults to (0, 0) here; the +/// detection path auto-detects a better phase when only the size is given. +pub fn override_grid(config: &GridDetectConfig) -> Option { + if let Some(grid) = config.override_grid { + // Programmatic grids (e.g. per sprite-sheet tile) are exact and + // never coarsened — the sheet-level grid was already coarsened once. + return Some(grid); + } + config.override_size.map(|size| { + coarsen( + Grid::from_size_phase(size, config.override_phase.unwrap_or((0, 0))), + config.coarsen, + ) + }) } -/// Detect the pixel grid size and phase offset in the image. -/// -/// Uses edge-alignment analysis: for each candidate grid size, we measure -/// where color transitions occur. In true pixel art at scale N, color changes -/// happen at grid boundaries (every Nth pixel) and NOT between them. -/// -/// The score = mean_diff_at_grid_lines / mean_diff_off_grid_lines. -/// The correct grid size maximizes this because ALL transitions align to its -/// grid. Smaller factors (N/2) score lower because only half their grid lines -/// coincide with real transitions. +/// Multiply a grid's pitch by an integer factor, preserving phase (coarse +/// block boundaries remain a subset of the fine grid's). +fn coarsen(grid: Grid, factor: u32) -> Grid { + if factor <= 1 { + return grid; + } + let f = factor as f32; + Grid::new( + grid.pitch_x * f, + grid.pitch_y * f, + grid.phase_x, + grid.phase_y, + ) +} + +/// Detect the pixel grid (pitch and phase) of the image. pub fn detect_grid(state: &mut PipelineState, config: &GridDetectConfig) -> Result<()> { let image = &state.image; let width = image.width(); let height = image.height(); // Handle overrides + if let Some(grid) = config.override_grid { + grid.validate(width, height)?; + info!(?grid, "Using full grid override"); + state.grid = Some(grid); + return Ok(()); + } if let Some(size) = config.override_size { - if let Some(phase) = config.override_phase { - // Both size and phase overridden — use as-is - info!(grid_size = size, phase_x = phase.0, phase_y = phase.1, "Using override grid size and phase"); - state.grid_size = Some(size); - state.grid_phase = Some(phase); - return Ok(()); - } + let base = if let Some(phase) = config.override_phase { + info!( + grid_size = size, + phase_x = phase.0, + phase_y = phase.1, + "Using override grid size and phase" + ); + Grid::from_size_phase(size, phase) + } else { + // Size overridden but phase not — auto-detect the phase for this + // pitch from the gradient profiles (works for fractional sizes). + let (cols, rows) = compute_profiles(image); + let (phase_x, _) = best_phase_for(&cols, size as f64, PHASE_STEP_FINE); + let (phase_y, _) = best_phase_for(&rows, size as f64, PHASE_STEP_FINE); + info!( + grid_size = size, + phase_x, phase_y, "Using override grid size with auto-detected phase" + ); + Grid::new(size, size, phase_x as f32, phase_y as f32) + }; + let grid = coarsen(base, config.coarsen); + grid.validate(width, height)?; + state.grid = Some(grid); + return Ok(()); + } - // Size overridden but phase not — auto-detect best phase for this size - let oklab_pixels: Vec = image.pixels().map(|p| rgba_to_oklab(*p)).collect(); - let best_phase = find_best_phase(&oklab_pixels, width, height, size); - info!( - grid_size = size, - phase_x = best_phase.0, - phase_y = best_phase.1, - "Using override grid size with auto-detected phase" - ); - state.grid_size = Some(size); - state.grid_phase = Some(best_phase); + if width < 4 || height < 4 { + warn!(width, height, "Image too small for grid detection"); return Ok(()); } - let max_candidate = config + let min_dim = width.min(height); + let max_pitch = config .max_candidate - .min(width / 2) - .min(height / 2) - .max(2); + .map(|m| m as f64) + .unwrap_or_else(|| ((min_dim / 4) as f64).clamp(16.0, 96.0)) + .min((min_dim / 2) as f64); - // Pre-compute OKLAB values for the entire image (done once) - let oklab_pixels: Vec = image.pixels().map(|p| rgba_to_oklab(*p)).collect(); + let (cols, rows) = compute_profiles(image); - // Phase 1: Coarse pass — test all candidate sizes with phase (0,0), in parallel - let coarse_results: Vec = (2..=max_candidate) - .into_par_iter() - .map(|candidate| { - let score = compute_edge_alignment( - &oklab_pixels, - width, - height, - candidate, - 0, - 0, - ); - CandidateResult { - grid_size: candidate, - phase_x: 0, - phase_y: 0, - score, - } - }) - .collect(); + // Per-axis fractional scan + let curve_x = scan_axis(&cols, max_pitch); + let curve_y = scan_axis(&rows, max_pitch); - // Store diagnostic scores - for r in &coarse_results { - debug!(grid_size = r.grid_size, score = r.score, "Coarse pass"); - state - .diagnostics - .grid_variance_scores - .push((r.grid_size, r.score)); - } - - // Sort by score descending - let mut sorted_coarse = coarse_results; - sorted_coarse.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap()); - - // Phase 2: Fine pass — scan phase offsets - // For small candidates (≤ threshold): exhaustive phase scan (cheap, small phase space) - // For large candidates in top-N: limited phase scan (subsample phase space) - let phase_scan_threshold = 8; - let num_top_candidates = 5.min(sorted_coarse.len()); - - // Collect all (candidate, phase_x, phase_y) jobs to run in parallel - let mut phase_jobs: Vec<(u32, u32, u32)> = Vec::new(); - - // Small candidates: exhaustive phase scan - for candidate in 2..=max_candidate.min(phase_scan_threshold) { - for phase_x in 0..candidate { - for phase_y in 0..candidate { - if phase_x == 0 && phase_y == 0 { - continue; // Already computed in coarse pass + // Diagnostics: thin the score curve to at most ~two entries per pitch + // unit so UIs can render it. + state.diagnostics.grid_scores = thin_curve(&curve_x); + + let Some((mut px, mut phx, sx)) = pick_candidate(&cols, &curve_x) else { + warn!("Grid detection found no candidates (horizontal axis)"); + return Ok(()); + }; + let Some((mut py, mut phy, sy)) = pick_candidate(&rows, &curve_y) else { + warn!("Grid detection found no candidates (vertical axis)"); + return Ok(()); + }; + + // Refine each axis (alternating parabolic pitch/phase refinement). + (px, phx) = refine_axis(&cols, px, phx); + (py, phy) = refine_axis(&rows, py, phy); + + // Cross-axis harmonic reconciliation: when one axis sits at an integer + // multiple of the other, the coarser one has usually locked onto a + // harmonic of the true pitch (dither and streak patterns make that + // easy). Re-refine the coarser axis at the finer axis's pitch and adopt + // it when it scores competitively — agreement between axes is strong + // evidence. A genuinely anisotropic image keeps its ratio, because the + // sub-pitch scores poorly there (same energy, double coverage). + { + let reconcile = |p_big: &mut f64, ph_big: &mut f64, prof: &AxisProfile, p_small: f64| { + let m = *p_big / p_small; + let m_round = m.round(); + if (2.0..=4.0).contains(&m_round) && (m - m_round).abs() < 0.06 * m_round { + let (cand_p, cand_ph) = refine_axis(prof, p_small, 0.0); + let cand_s = score_axis(prof, cand_p, cand_ph); + let cur_s = score_axis(prof, *p_big, *ph_big); + if cand_s * TIE_FACTOR >= cur_s { + debug!( + from = *p_big, + to = cand_p, + "Cross-axis reconciliation: adopting the finer pitch" + ); + *p_big = cand_p; + *ph_big = cand_ph; } - phase_jobs.push((candidate, phase_x, phase_y)); } + }; + if px > py { + reconcile(&mut px, &mut phx, &cols, py); + } else if py > px { + reconcile(&mut py, &mut phy, &rows, px); } } - // Top candidates above threshold: limited phase scan - // Only scan phases 0..min(candidate, 8) for each axis - for r in &sorted_coarse[..num_top_candidates] { - if r.grid_size <= phase_scan_threshold { - continue; // Already covered above + // Square-grid policy: constrain equal when the two pitches agree within + // tolerance; re-refine each axis's phase at the shared pitch. + let tol = (0.02 * px.max(py)).max(0.25); + if (px - py).abs() <= tol && (px - py).abs() > f64::EPSILON { + let shared = (px * sx + py * sy) / (sx + sy).max(f64::EPSILON); + px = shared; + py = shared; + (phx, _) = best_phase_for(&cols, shared, PHASE_STEP_FINE); + (phy, _) = best_phase_for(&rows, shared, PHASE_STEP_FINE); + } + + // Integer snap: clean upscales must come out exact. A nearby integer + // pitch (with re-scanned integer-rounded phase) is preferred whenever it + // scores essentially as well as the refined fractional estimate. + for (p, ph, prof) in [(&mut px, &mut phx, &cols), (&mut py, &mut phy, &rows)] { + let p_int = p.round(); + if p_int < 2.0 || (*p - p_int).abs() >= 0.5 { + continue; } - let max_phase = r.grid_size.min(8); - for phase_x in 0..max_phase { - for phase_y in 0..max_phase { - if phase_x == 0 && phase_y == 0 { - continue; - } - phase_jobs.push((r.grid_size, phase_x, phase_y)); - } + let (int_phase, int_score) = best_phase_for(prof, p_int, PHASE_STEP_FINE); + let refined_score = score_axis(prof, *p, *ph); + if int_score >= refined_score * INT_SNAP_TOLERANCE { + *p = p_int; + *ph = if (int_phase - int_phase.round()).abs() < INT_SNAP_PHASE { + int_phase.round().rem_euclid(p_int) + } else { + int_phase + }; } } - // Run all phase jobs in parallel - let phase_results: Vec = phase_jobs - .par_iter() - .map(|&(candidate, phase_x, phase_y)| { - let score = compute_edge_alignment( - &oklab_pixels, - width, - height, - candidate, - phase_x, - phase_y, - ); - CandidateResult { - grid_size: candidate, - phase_x, - phase_y, - score, - } - }) - .collect(); - - // Find the best overall result (from coarse + phase results) - let mut best = sorted_coarse[0].clone(); - for r in &phase_results { - if r.score > best.score { - best = r.clone(); - } + let detected = Grid::new(px as f32, py as f32, phx as f32, phy as f32); + let grid = coarsen(detected, config.coarsen); + if grid.validate(width, height).is_err() { + warn!( + ?grid, + "Detected grid failed validation; leaving image untouched" + ); + return Ok(()); } - // Compute confidence based on how much the best score exceeds the runner-up - // at a different grid size - let runner_up = sorted_coarse - .iter() - .find(|r| r.grid_size != best.grid_size); - let confidence = match runner_up { - Some(r) if best.score > 0.0 => { - (1.0 - r.score / best.score).clamp(0.0, 1.0) - } - _ => 1.0, - }; + // Confidence: edge quality at the chosen grid x separation from the best + // non-harmonic alternative. + let quality = quality_axis(&cols, px, phx).min(quality_axis(&rows, py, phy)); + let separation = separation_score(&curve_x, px).min(separation_score(&curve_y, py)); + let confidence = (quality * separation).max(0.0).sqrt() as f32; + + debug!(px, py, phx, phy, quality, separation, "Grid candidate"); + + // Diagnostics keep the RAW detection; state.grid gets the coarsened one. + state.diagnostics.grid_best_guess = Some(detected); + state.diagnostics.grid_confidence = Some(confidence); + + if confidence < config.min_confidence { + warn!( + pitch_x = px, + pitch_y = py, + confidence, + "Grid detection confidence below floor; not snapping (pass --grid-size to force)" + ); + return Ok(()); + } info!( - grid_size = best.grid_size, - phase_x = best.phase_x, - phase_y = best.phase_y, - score = best.score, + pitch_x = px, + pitch_y = py, + phase_x = phx, + phase_y = phy, confidence, "Grid detected" ); - - state.grid_size = Some(best.grid_size); - state.grid_phase = Some((best.phase_x, best.phase_y)); - state.diagnostics.grid_confidence = Some(confidence); + state.grid = Some(grid); Ok(()) } -/// Find the best phase offset for a given grid size by testing all possibilities. +/// Gradient profile along one axis. `energy[j]`/`weight[j]` describe the +/// transition between position `j-1` and `j` (valid for `1 <= j < len`). +struct AxisProfile { + energy: Vec, + weight: Vec, + total_energy: f64, + total_weight: f64, + len: u32, +} + +/// Reduce the image to per-column and per-row gradient profiles. /// -/// For small grid sizes (≤12), scans all phase_x × phase_y combinations. -/// For larger sizes, scans a limited window (0..12 for each axis). -/// All jobs run in parallel via rayon. -fn find_best_phase( - oklab_pixels: &[Oklab], - width: u32, - height: u32, - grid_size: u32, -) -> (u32, u32) { - let max_phase = grid_size.min(12); - - let results: Vec<(u32, u32, f32)> = (0..max_phase) +/// Pair energy is alpha-aware: the color term is gated by the smaller alpha +/// (transparent pixels carry garbage RGB that must not contribute) and an +/// alpha-difference term makes silhouette edges count as evidence. The pair +/// weight is the larger alpha, so fully transparent regions are dead weight. +fn compute_profiles(image: &image::RgbaImage) -> (AxisProfile, AxisProfile) { + let width = image.width() as usize; + let height = image.height() as usize; + + let oklab: Vec = image.pixels().map(|p| rgba_to_oklab(*p)).collect(); + let alpha: Vec = image.pixels().map(|p| p[3] as f32 / 255.0).collect(); + + let pair = |i1: usize, i2: usize| -> (f64, f64) { + let a1 = alpha[i1]; + let a2 = alpha[i2]; + let color = a1.min(a2) * oklab_distance_sq(oklab[i1], oklab[i2]); + let da = a1 - a2; + ((color + da * da) as f64, a1.max(a2) as f64) + }; + + // Both profiles accumulated in one parallel pass over rows. + let (col_acc, row_acc) = (0..height) .into_par_iter() - .flat_map(|px| { - (0..max_phase) - .into_par_iter() - .map(move |py| { - let score = - compute_edge_alignment(oklab_pixels, width, height, grid_size, px, py); - (px, py, score) - }) - }) - .collect(); + .fold( + || { + ( + vec![(0.0f64, 0.0f64); width], + vec![(0.0f64, 0.0f64); height], + ) + }, + |(mut cols, mut rows), y| { + let row = y * width; + for (x, col) in cols.iter_mut().enumerate().skip(1) { + let (pe, pw) = pair(row + x - 1, row + x); + col.0 += pe; + col.1 += pw; + } + if y >= 1 { + let prev = (y - 1) * width; + for x in 0..width { + let (pe, pw) = pair(prev + x, row + x); + rows[y].0 += pe; + rows[y].1 += pw; + } + } + (cols, rows) + }, + ) + .reduce( + || { + ( + vec![(0.0f64, 0.0f64); width], + vec![(0.0f64, 0.0f64); height], + ) + }, + |(mut c1, mut r1), (c2, r2)| { + for i in 0..c1.len() { + c1[i].0 += c2[i].0; + c1[i].1 += c2[i].1; + } + for i in 0..r1.len() { + r1[i].0 += r2[i].0; + r1[i].1 += r2[i].1; + } + (c1, r1) + }, + ); + + let make = |acc: Vec<(f64, f64)>, len: usize| { + let energy: Vec = acc.iter().map(|&(e, _)| e).collect(); + let weight: Vec = acc.iter().map(|&(_, w)| w).collect(); + AxisProfile { + total_energy: energy.iter().sum(), + total_weight: weight.iter().sum(), + energy, + weight, + len: len as u32, + } + }; + + (make(col_acc, width), make(row_acc, height)) +} - results - .into_iter() - .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap()) - .map(|(px, py, _)| (px, py)) - .unwrap_or((0, 0)) +/// Score a (pitch, phase) comb against a profile: energy concentration. +/// +/// `captured energy fraction − captured weight (coverage) fraction`. A comb +/// that captures all gradient energy with sparse coverage scores near 1; a +/// comb whose capture is proportional to its coverage (noise, or a vacuous +/// dense comb like pitch 2 covering every index) scores 0. This makes +/// sub-pitches (pitch/2) score strictly lower than the true pitch — they +/// capture the same energy with double the coverage. +fn score_axis(p: &AxisProfile, pitch: f64, phase: f64) -> f64 { + if p.total_energy <= f64::EPSILON { + return 0.0; + } + // Two window policies, scored under both, best kept: single-index + // windows discriminate sharp small-pitch grids (a pair window at pitch 2 + // covers every index and is vacuous), pair windows catch soft AA/ringing + // edges whose energy spreads over two indices. Taking the max keeps the + // score continuous in pitch — a hard switch at some pitch threshold + // would let 3.99 always beat 4.0 or vice versa. + let mut best = f64::MIN; + for pair_always in [false, true] { + let (on_e, on_w, n_bounds) = capture(p, pitch, phase, pair_always); + if n_bounds == 0 { + continue; + } + let s = on_e / p.total_energy - on_w / p.total_weight.max(f64::EPSILON); + if s > best { + best = s; + } + } + if best == f64::MIN { + 0.0 + } else { + best + } } -/// Compute edge alignment score for a candidate grid size and phase. +/// Edge quality in [0, 1] at a (pitch, phase): the comb's energy +/// concentration normalized by the best it could possibly be at its +/// coverage — 1 when the comb explains all gradient energy, 0 when its +/// capture is proportional to coverage (nothing grid-like). /// -/// Measures the ratio of color gradients at grid boundaries vs non-boundaries. -/// For the correct grid size, transitions happen at grid lines and nowhere else, -/// so this ratio is maximized. +/// The windows are widened by ±1px when the pitch allows: soft edges from +/// AA or upscaler ringing spread energy over two indices and must not read +/// as unexplained. +fn quality_axis(p: &AxisProfile, pitch: f64, phase: f64) -> f64 { + let len = p.len as i64; + let mut on_e = 0.0; + let mut on_w = 0.0; + let mut n: u32 = 0; + let mut last_hi: i64 = 0; + let widen: i64 = if pitch >= 3.0 { 1 } else { 0 }; + + let start = if phase > 0.0 { phase } else { phase + pitch }; + let mut i: i64 = 0; + loop { + let b = start + i as f64 * pitch; + i += 1; + if b >= len as f64 { + break; + } + if b <= 0.0 { + continue; + } + let lo = ((b.floor() as i64) - widen) + .clamp(1, len - 1) + .max(last_hi + 1); + let hi = ((b.ceil() as i64) + widen).clamp(1, len - 1); + for j in lo..=hi { + on_e += p.energy[j as usize]; + on_w += p.weight[j as usize]; + } + last_hi = hi; + n += 1; + } + if n == 0 || p.total_energy <= f64::EPSILON { + return 0.0; + } + let e_frac = on_e / p.total_energy; + let w_frac = (on_w / p.total_weight.max(f64::EPSILON)).min(1.0 - 1e-6); + ((e_frac - w_frac) / (1.0 - w_frac)).clamp(0.0, 1.0) +} + +/// Capture the per-boundary gradients: returns (energy, weight, count). /// -/// Examines both horizontal transitions (between adjacent columns) and -/// vertical transitions (between adjacent rows). -fn compute_edge_alignment( - oklab_pixels: &[Oklab], - width: u32, - height: u32, - grid_size: u32, - phase_x: u32, - phase_y: u32, -) -> f32 { - let mut on_grid_total = 0.0f64; - let mut on_grid_count = 0u64; - let mut off_grid_total = 0.0f64; - let mut off_grid_count = 0u64; - - // Vertical transitions (between row y-1 and row y) - for y in 1..height { - // A grid line occurs at positions where a new block starts - let is_grid_line = if y >= phase_y { - (y - phase_y).is_multiple_of(grid_size) +/// A boundary near an integer position claims exactly that transition index; +/// a genuinely fractional boundary claims the floor/ceil pair (the physical +/// transition sits at one of the two depending on the generator's rounding +/// convention). Fixed windows keep a slightly-wrong pitch from +/// cherry-picking neighboring edges (which a max-over-band search would), +/// so the estimate carries no upward bias. +fn capture(p: &AxisProfile, pitch: f64, phase: f64, pair_always: bool) -> (f64, f64, u32) { + let len = p.len as i64; + let mut on_e = 0.0; + let mut on_w = 0.0; + let mut n: u32 = 0; + let mut last: i64 = 0; + + // Interior boundaries only: b in (0, len). Start from the first > 0. + let start = if phase > 0.0 { phase } else { phase + pitch }; + let mut i: i64 = 0; + loop { + let b = start + i as f64 * pitch; + i += 1; + if b >= len as f64 { + break; + } + if b <= 0.0 { + continue; + } + // Window per boundary: the pair {ceil(b)-1, ceil(b)} — for an + // integer boundary the edge plus the index an AA blend pixel + // occupies, for a fractional one the floor/ceil rounding-convention + // pair — or the single nearest index when the boundary is + // near-integer and the caller asked for tight windows. + let (lo, hi) = if !pair_always && (b - b.round()).abs() < 0.25 { + let c = (b.round() as i64).clamp(1, len - 1); + (c, c) } else { - false + let hi = (b.ceil() as i64).clamp(1, len - 1); + ((hi - 1).max(1), hi) }; + for j in lo.max(last + 1)..=hi { + on_e += p.energy[j as usize]; + on_w += p.weight[j as usize]; + } + last = hi; + n += 1; + } + (on_e, on_w, n) +} - for x in 0..width { - let idx_curr = (y * width + x) as usize; - let idx_prev = ((y - 1) * width + x) as usize; - let diff = oklab_distance_sq(oklab_pixels[idx_curr], oklab_pixels[idx_prev]); - - if is_grid_line { - on_grid_total += diff as f64; - on_grid_count += 1; - } else { - off_grid_total += diff as f64; - off_grid_count += 1; - } +/// Best phase in [0, pitch) for a given pitch, scanned at `step`. +fn best_phase_for(p: &AxisProfile, pitch: f64, step: f64) -> (f64, f64) { + let mut best = (0.0, f64::MIN); + let mut phase = 0.0; + while phase < pitch { + let s = score_axis(p, pitch, phase); + if s > best.1 { + best = (phase, s); } + phase += step; } + best +} - // Horizontal transitions (between column x-1 and column x) - for y in 0..height { - for x in 1..width { - let is_grid_line = if x >= phase_x { - (x - phase_x).is_multiple_of(grid_size) - } else { - false - }; +/// Scan all pitches in [2, max_pitch] with an adaptive step; returns +/// (pitch, best_phase, score) per sample. +fn scan_axis(p: &AxisProfile, max_pitch: f64) -> Vec<(f64, f64, f64)> { + let len = p.len as f64; + let mut pitches = Vec::new(); + let mut pitch = 2.0; + while pitch <= max_pitch { + pitches.push(pitch); + // Bound comb drift across the axis to <= 0.5px. + pitch += (pitch * pitch / (2.0 * len)).clamp(0.01, 0.5); + } + + pitches + .into_par_iter() + .map(|pitch| { + let (phase, score) = best_phase_for(p, pitch, PHASE_STEP); + (pitch, phase, score) + }) + .collect() +} + +/// Harmonic rule: take the best-scoring sample, then prefer an integer +/// multiple of its pitch when the multiple — after refinement — scores +/// within TIE_FACTOR. A sub-pitch (pitch/2) scores comparably to the true +/// pitch because every one of its boundaries is consistent with the finer +/// comb, so among the harmonic family the largest competitive pitch is the +/// true one. +fn pick_candidate(p: &AxisProfile, curve: &[(f64, f64, f64)]) -> Option<(f64, f64, f64)> { + let &(bp, bph, bs) = curve + .iter() + .max_by(|a, b| a.2.total_cmp(&b.2)) + .filter(|&&(_, _, s)| s > 0.0)?; + + let max_pitch = curve.last().map(|&(pt, _, _)| pt).unwrap_or(bp); + let mut winner = (bp, bph, bs); + for m in [2.0, 3.0, 4.0] { + let target = bp * m; + if target > max_pitch { + break; + } + // Seed from the best sample near the multiple, then refine. + let Some(&(sp, sph, _)) = curve + .iter() + .filter(|&&(pt, _, _)| (pt / target - 1.0).abs() < 0.06) + .max_by(|a, b| a.2.total_cmp(&b.2)) + else { + continue; + }; + let (rp, rph) = refine_axis(p, sp, sph); + let rs = score_axis(p, rp, rph); + if rs * TIE_FACTOR >= bs { + winner = (rp, rph, rs); + } + } + Some(winner) +} - let idx_curr = (y * width + x) as usize; - let idx_prev = (y * width + x - 1) as usize; - let diff = oklab_distance_sq(oklab_pixels[idx_curr], oklab_pixels[idx_prev]); +/// Best score among pitches outside the winner's harmonic family, as a +/// separation measure in [0, 1]. +fn separation_score(curve: &[(f64, f64, f64)], winner_pitch: f64) -> f64 { + let is_harmonic = |p: f64| { + let r = if p > winner_pitch { + p / winner_pitch + } else { + winner_pitch / p + }; + (r - r.round()).abs() < 0.08 * r.round().max(1.0) + }; + let winner_score = curve + .iter() + .filter(|&&(p, _, _)| (p - winner_pitch).abs() < 0.5) + .map(|&(_, _, s)| s) + .fold(0.0f64, f64::max) + .max(f64::EPSILON); + let best_other = curve + .iter() + .filter(|&&(p, _, _)| !is_harmonic(p)) + .map(|&(_, _, s)| s) + .fold(0.0f64, f64::max); + if best_other <= 0.0 { + return 1.0; + } + (1.0 - best_other / winner_score).clamp(0.0, 1.0) +} - if is_grid_line { - on_grid_total += diff as f64; - on_grid_count += 1; +/// Alternate pitch refinement (shrinking-step local search) with fine phase +/// re-scan. The score surface is a staircase near the peak, so a fixed-step +/// parabola is unstable; halving the step each round converges instead. +fn refine_axis(p: &AxisProfile, mut pitch: f64, mut phase: f64) -> (f64, f64) { + let len = p.len as f64; + let mut dp = (pitch * pitch / (2.0 * len)).clamp(0.01, 0.5); + for _ in 0..5 { + // Evaluate the three-point neighborhood at the current step; move to + // the best, or stay and shrink. + let s = |pt: f64| { + if pt < 2.0 { + f64::MIN } else { - off_grid_total += diff as f64; - off_grid_count += 1; + best_phase_for(p, pt, PHASE_STEP_FINE).1 + } + }; + let (s0, s1, s2) = (s(pitch - dp), s(pitch), s(pitch + dp)); + if s0 > s1 && s0 >= s2 { + pitch -= dp; + } else if s2 > s1 && s2 > s0 { + pitch += dp; + } else { + // Local max: parabolic sub-step estimate, then shrink. + let denom = s0 - 2.0 * s1 + s2; + if denom.abs() > f64::EPSILON { + let offset = 0.5 * (s0 - s2) / denom; + if offset.abs() <= 1.0 { + pitch += offset * dp; + } } + dp *= 0.5; } + pitch = pitch.max(2.0); } - if on_grid_count == 0 { - return 0.0; + // Fine phase re-scan around the final pitch. + let mut best = (phase, score_axis(p, pitch, phase)); + let mut ph = 0.0; + while ph < pitch { + let sc = score_axis(p, pitch, ph); + if sc > best.1 { + best = (ph, sc); + } + ph += PHASE_STEP_FINE; } + phase = best.0; + (pitch, phase) +} - let on_avg = on_grid_total / on_grid_count as f64; - let off_avg = if off_grid_count > 0 { - off_grid_total / off_grid_count as f64 - } else { - 0.0 - }; - - (on_avg / (off_avg + 1e-10)) as f32 +/// Thin a score curve to at most ~2 samples per pitch unit (max per bucket) +/// for display purposes. +fn thin_curve(curve: &[(f64, f64, f64)]) -> Vec<(f32, f32)> { + let mut buckets: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for &(pitch, _, score) in curve { + let key = (pitch * 2.0).round() as i64; + let entry = buckets.entry(key).or_insert((pitch, score)); + if score > entry.1 { + *entry = (pitch, score); + } + } + buckets + .values() + .map(|&(p, s)| (p as f32, s as f32)) + .collect() } #[cfg(test)] @@ -314,233 +654,300 @@ mod tests { use super::*; use image::{Rgba, RgbaImage}; - /// Create a synthetic pixel art image: a small image upscaled by `scale`. - fn make_synthetic(small_w: u32, small_h: u32, scale: u32) -> RgbaImage { - let colors = [ - Rgba([255, 0, 0, 255]), - Rgba([0, 255, 0, 255]), - Rgba([0, 0, 255, 255]), - Rgba([255, 255, 0, 255]), - Rgba([255, 0, 255, 255]), - Rgba([0, 255, 255, 255]), - Rgba([128, 128, 128, 255]), - Rgba([255, 128, 0, 255]), - ]; - - let big_w = small_w * scale; - let big_h = small_h * scale; - let mut img = RgbaImage::new(big_w, big_h); - - for sy in 0..small_h { - for sx in 0..small_w { - let color_idx = ((sy * 3 + sx * 7 + sy * sx) as usize) % colors.len(); - let color = colors[color_idx]; - for dy in 0..scale { - for dx in 0..scale { - img.put_pixel(sx * scale + dx, sy * scale + dy, color); - } + /// Render an n x n logical sprite at an arbitrary (possibly fractional) + /// pitch using the round-edge convention. + fn render_at_pitch(cells: u32, pitch: f64, phase: f64, colors: &[Rgba]) -> RgbaImage { + let size = (cells as f64 * pitch + phase).ceil() as u32; + let mut img = RgbaImage::from_pixel(size, size, Rgba([128, 128, 128, 255])); + for y in 0..size { + for x in 0..size { + if (x as f64) < phase || (y as f64) < phase { + continue; } + let cx = ((x as f64 - phase) / pitch).floor() as usize; + let cy = ((y as f64 - phase) / pitch).floor() as usize; + let color = colors[(cy * 31 + cx * 17 + cy * cx * 7) % colors.len()]; + img.put_pixel(x, y, color); } } - img } - /// Create a synthetic pixel art image with AA noise at block boundaries. - fn make_synthetic_with_aa(small_w: u32, small_h: u32, scale: u32) -> RgbaImage { - let colors = [ + fn palette() -> Vec> { + vec![ Rgba([255, 0, 0, 255]), Rgba([0, 255, 0, 255]), Rgba([0, 0, 255, 255]), Rgba([255, 255, 0, 255]), - Rgba([255, 0, 255, 255]), - Rgba([0, 255, 255, 255]), - ]; - - let big_w = small_w * scale; - let big_h = small_h * scale; - let mut img = RgbaImage::new(big_w, big_h); - - let mut sprite = vec![vec![Rgba([0u8, 0, 0, 255]); small_w as usize]; small_h as usize]; - for sy in 0..small_h { - for sx in 0..small_w { - let idx = ((sy * 3 + sx * 7 + sy * sx) as usize) % colors.len(); - sprite[sy as usize][sx as usize] = colors[idx]; - } - } + Rgba([30, 30, 30, 255]), + Rgba([220, 220, 220, 255]), + ] + } - // Upscale - for sy in 0..small_h { - for sx in 0..small_w { - let color = sprite[sy as usize][sx as usize]; - for dy in 0..scale { - for dx in 0..scale { - img.put_pixel(sx * scale + dx, sy * scale + dy, color); - } + fn detect(img: RgbaImage) -> PipelineState { + let mut state = PipelineState::new(img); + detect_grid(&mut state, &GridDetectConfig::default()).unwrap(); + state + } + + #[test] + fn test_detect_grid_scale_4() { + let img = render_at_pitch(8, 4.0, 0.0, &palette()); + let state = detect(img); + let grid = state.grid.expect("should detect"); + assert_eq!(grid.pitch_x, 4.0, "pitch_x"); + assert_eq!(grid.pitch_y, 4.0, "pitch_y"); + assert_eq!(grid.display_phase(), (0.0, 0.0)); + } + + #[test] + fn test_detect_grid_scale_2() { + let img = render_at_pitch(16, 2.0, 0.0, &palette()); + let state = detect(img); + assert_eq!(state.grid.expect("should detect").pitch_x, 2.0); + } + + #[test] + fn test_detect_grid_scale_8() { + let img = render_at_pitch(6, 8.0, 0.0, &palette()); + let state = detect(img); + assert_eq!(state.grid.expect("should detect").pitch_x, 8.0); + } + + #[test] + fn test_detect_fractional_pitch() { + // 12 cells at pitch 10.667 → 128px. Integer-only detection could + // never find this. + let pitch = 128.0 / 12.0; + let img = render_at_pitch(12, pitch, 0.0, &palette()); + let state = detect(img); + let grid = state.grid.expect("should detect fractional pitch"); + assert!( + (grid.pitch_x as f64 - pitch).abs() < 0.05, + "pitch_x {} != {}", + grid.pitch_x, + pitch + ); + } + + #[test] + fn test_detect_with_large_margin() { + // 13px margin exceeds the old 8/12px phase-scan caps. + let img = render_at_pitch(8, 6.0, 13.0, &palette()); + let state = detect(img); + let grid = state.grid.expect("should detect"); + assert_eq!(grid.pitch_x, 6.0); + assert_eq!(grid.display_phase().0, 1.0); // 13 mod 6 + } + + #[test] + fn test_harmonic_ripple_prefers_true_pitch() { + // Clean 4x upscale plus a 2px alternating brightness ripple: the + // ripple gives pitch 2 a strong comb, but the true pitch is 4. + let mut img = render_at_pitch(12, 4.0, 0.0, &palette()); + for y in 0..img.height() { + for x in 0..img.width() { + if x % 2 == 0 { + let p = img.get_pixel_mut(x, y); + p[0] = p[0].saturating_add(6); + p[1] = p[1].saturating_add(6); + p[2] = p[2].saturating_add(6); } } } + let state = detect(img); + let grid = state.grid.expect("should detect"); + assert_eq!(grid.pitch_x, 4.0, "must not lock onto the 2px ripple"); + } - // Inject AA at horizontal boundaries - for sy in 0..small_h - 1 { - for sx in 0..small_w { - let c1 = sprite[sy as usize][sx as usize]; - let c2 = sprite[(sy + 1) as usize][sx as usize]; - if c1 != c2 { - let blend = Rgba([ - ((c1[0] as u16 + c2[0] as u16) / 2) as u8, - ((c1[1] as u16 + c2[1] as u16) / 2) as u8, - ((c1[2] as u16 + c2[2] as u16) / 2) as u8, - 255, - ]); - let boundary_y = (sy + 1) * scale; - for dx in 0..scale { - if boundary_y > 0 { - img.put_pixel(sx * scale + dx, boundary_y - 1, blend); - } - img.put_pixel(sx * scale + dx, boundary_y, blend); - } + #[test] + fn test_transparent_rgb_garbage_ignored() { + // The RGB channels of fully transparent pixels are undefined data and + // must not affect detection: two images identical except for the + // hidden RGB in their transparent frame must detect identically. + let base = render_at_pitch(8, 5.0, 0.0, &palette()); + let (w, h) = (base.width(), base.height()); + let mut clean = base.clone(); + let mut dirty = base; + for y in 0..h { + for x in 0..w { + if x < 3 || y < 3 || x >= w - 3 || y >= h - 3 { + clean.put_pixel(x, y, Rgba([0, 0, 0, 0])); + let noise = ((x * 7919 + y * 104729) % 256) as u8; + dirty.put_pixel( + x, + y, + Rgba([noise, noise.wrapping_mul(3), noise.wrapping_add(91), 0]), + ); } } } + let s1 = detect(clean); + let s2 = detect(dirty); + let g1 = s1.diagnostics.grid_best_guess.expect("clean has a guess"); + let g2 = s2.diagnostics.grid_best_guess.expect("dirty has a guess"); + assert_eq!(g1.pitch_x, g2.pitch_x, "hidden RGB changed the estimate"); + assert_eq!(g1.phase_x, g2.phase_x); + assert_eq!( + s1.diagnostics.grid_confidence, + s2.diagnostics.grid_confidence + ); + } - // Inject AA at vertical boundaries - for sy in 0..small_h { - for sx in 0..small_w - 1 { - let c1 = sprite[sy as usize][sx as usize]; - let c2 = sprite[sy as usize][(sx + 1) as usize]; - if c1 != c2 { - let blend = Rgba([ - ((c1[0] as u16 + c2[0] as u16) / 2) as u8, - ((c1[1] as u16 + c2[1] as u16) / 2) as u8, - ((c1[2] as u16 + c2[2] as u16) / 2) as u8, - 255, - ]); - let boundary_x = (sx + 1) * scale; - for dy in 0..scale { - if boundary_x > 0 { - img.put_pixel(boundary_x - 1, sy * scale + dy, blend); - } - img.put_pixel(boundary_x, sy * scale + dy, blend); - } - } + #[test] + fn test_detect_grid_with_aa_noise() { + // Blend colors at cell boundaries (simple AA simulation). + let mut img = render_at_pitch(8, 4.0, 0.0, &palette()); + let w = img.width(); + let h = img.height(); + for y in 0..h { + for bx in (4..w).step_by(4) { + let left = *img.get_pixel(bx - 2, y); + let right = *img.get_pixel(bx, y); + let blend = Rgba([ + ((left[0] as u16 + right[0] as u16) / 2) as u8, + ((left[1] as u16 + right[1] as u16) / 2) as u8, + ((left[2] as u16 + right[2] as u16) / 2) as u8, + 255, + ]); + img.put_pixel(bx - 1, y, blend); } } - - img + let state = detect(img); + assert_eq!(state.grid.expect("should detect").pitch_x, 4.0); } #[test] - fn test_detect_grid_scale_4() { - let img = make_synthetic(8, 8, 4); + fn test_override_grid_size() { + let img = render_at_pitch(8, 4.0, 0.0, &palette()); let config = GridDetectConfig { - max_candidate: 16, + override_size: Some(6.0), + override_phase: Some((1, 2)), ..Default::default() }; let mut state = PipelineState::new(img); detect_grid(&mut state, &config).unwrap(); - - assert_eq!(state.grid_size, Some(4)); - assert_eq!(state.grid_phase, Some((0, 0))); + assert_eq!(state.grid, Some(Grid::from_integer(6, (1, 2)))); } #[test] - fn test_detect_grid_scale_2() { - let img = make_synthetic(16, 16, 2); + fn test_override_applies_when_detection_skipped() { let config = GridDetectConfig { - max_candidate: 16, + override_size: Some(6.0), + override_phase: Some((1, 2)), + skip: true, ..Default::default() }; - let mut state = PipelineState::new(img); - detect_grid(&mut state, &config).unwrap(); - - assert_eq!(state.grid_size, Some(2)); + assert_eq!(override_grid(&config), Some(Grid::from_integer(6, (1, 2)))); } #[test] - fn test_detect_grid_scale_8() { - let img = make_synthetic(4, 4, 8); - let config = GridDetectConfig { - max_candidate: 16, - ..Default::default() - }; - let mut state = PipelineState::new(img); - detect_grid(&mut state, &config).unwrap(); + fn test_cross_axis_harmonic_reconciliation() { + // True pitch 3 on both axes, but ~90% of horizontal cell PAIRS share + // a color, so the 6px harmonic scores within the tie factor on x and + // the prefer-larger rule locks onto it (the telescope failure mode). + // The y-axis stays uncorrelated and finds 3; reconciliation must + // pull x back down to agree. + let cells = 24u32; + let pitch = 3u32; + let size = cells * pitch; + let mut img = RgbaImage::new(size, size); + let pal = palette(); + let mut seed = 7u32; + let mut cell_colors = vec![Rgba([0, 0, 0, 255]); (cells * cells) as usize]; + for cy in 0..cells as usize { + for cx in 0..cells as usize { + let base = pal[((cx / 2) * 17 + cy * 31 + (cx / 2) * cy * 7) % pal.len()]; + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let broken = (seed % 100) >= 90; + cell_colors[cy * cells as usize + cx] = if broken { + pal[(cx * 13 + cy * 5 + 3) % pal.len()] + } else { + base + }; + } + } + for y in 0..size { + for x in 0..size { + let idx = (y / pitch) as usize * cells as usize + (x / pitch) as usize; + img.put_pixel(x, y, cell_colors[idx]); + } + } - assert_eq!(state.grid_size, Some(8)); + let state = detect(img); + let grid = state + .grid + .or(state.diagnostics.grid_best_guess) + .expect("some guess"); + assert!( + (grid.pitch_x - 3.0).abs() < 0.1, + "x must reconcile to the true pitch, got {}", + grid.pitch_x + ); + assert!((grid.pitch_y - 3.0).abs() < 0.1); } #[test] - fn test_detect_grid_with_aa_noise() { - let img = make_synthetic_with_aa(8, 8, 4); + fn test_coarsen_multiplies_detected_pitch_keeps_phase() { + let img = render_at_pitch(8, 4.0, 13.0, &palette()); let config = GridDetectConfig { - max_candidate: 16, + coarsen: 2, ..Default::default() }; let mut state = PipelineState::new(img); detect_grid(&mut state, &config).unwrap(); - - assert_eq!(state.grid_size, Some(4)); + let grid = state.grid.expect("should detect"); + assert_eq!(grid.pitch_x, 8.0, "detected 4.0 coarsened by 2"); + // Phase preserved: 13 mod 4 = 1 on the fine grid, and the coarse + // boundaries stay a subset of the fine ones. + assert_eq!(grid.display_phase().0, (13.0f32 - 8.0).rem_euclid(8.0)); + // Diagnostics keep the raw detection. + assert_eq!(state.diagnostics.grid_best_guess.unwrap().pitch_x, 4.0); } #[test] - fn test_override_grid_size() { - let img = make_synthetic(8, 8, 4); - let config = GridDetectConfig { - override_size: Some(6), - override_phase: Some((1, 2)), + fn test_coarsen_applies_to_override_size_not_override_grid() { + let sized = GridDetectConfig { + override_size: Some(3.0), + override_phase: Some((0, 0)), + coarsen: 2, ..Default::default() }; - let mut state = PipelineState::new(img); - detect_grid(&mut state, &config).unwrap(); + assert_eq!(override_grid(&sized).unwrap().pitch_x, 6.0); - assert_eq!(state.grid_size, Some(6)); - assert_eq!(state.grid_phase, Some((1, 2))); + let programmatic = GridDetectConfig { + override_grid: Some(Grid::from_integer(3, (0, 0))), + coarsen: 2, + ..Default::default() + }; + assert_eq!( + override_grid(&programmatic).unwrap().pitch_x, + 3.0, + "programmatic grids are exact and never coarsened" + ); } #[test] - fn test_detect_with_phase_offset() { - let scale = 4u32; - let small_w = 6u32; - let small_h = 6u32; - let offset = 1u32; - let big_w = small_w * scale + offset; - let big_h = small_h * scale + offset; - - let colors = [ - Rgba([255, 0, 0, 255]), - Rgba([0, 255, 0, 255]), - Rgba([0, 0, 255, 255]), - Rgba([255, 255, 0, 255]), - ]; - - let mut img = RgbaImage::new(big_w, big_h); - for pixel in img.pixels_mut() { - *pixel = Rgba([128, 128, 128, 255]); + fn test_low_confidence_declines_to_snap() { + // Uniform noise has no grid; detection must not invent one. + let mut img = RgbaImage::new(64, 64); + let mut seed = 0x12345678u32; + for p in img.pixels_mut() { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + *p = Rgba([ + (seed >> 8) as u8, + (seed >> 16) as u8, + (seed >> 24) as u8, + 255, + ]); } - for sy in 0..small_h { - for sx in 0..small_w { - let color = colors[((sy * 3 + sx * 7 + sy * sx) as usize) % colors.len()]; - for dy in 0..scale { - for dx in 0..scale { - let px = offset + sx * scale + dx; - let py = offset + sy * scale + dy; - if px < big_w && py < big_h { - img.put_pixel(px, py, color); - } - } - } - } - } - - let config = GridDetectConfig { - max_candidate: 16, - ..Default::default() - }; - let mut state = PipelineState::new(img); - detect_grid(&mut state, &config).unwrap(); - - assert_eq!(state.grid_size, Some(4)); - assert_eq!(state.grid_phase, Some((1, 1))); + let state = detect(img); + assert!( + state.grid.is_none(), + "noise must stay unsnapped (confidence {:?})", + state.diagnostics.grid_confidence + ); + // But a best guess is still reported. + assert!(state.diagnostics.grid_best_guess.is_some()); } } diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index d394476..9d5a11f 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -1,27 +1,111 @@ pub mod aa_removal; pub mod background; +pub mod dither; pub mod downscale; +pub mod grid; pub mod grid_detect; +pub mod options; pub mod quantize; -use anyhow::Result; +use crate::error::Result; use image::RgbaImage; use tracing::info; pub use aa_removal::AaRemovalConfig; -pub use background::{BackgroundConfig, detect_border_color, is_bg_pixel}; -pub use downscale::DownscaleMode; +pub use background::{detect_border_color, is_bg_pixel, BackgroundConfig}; +pub use dither::{DitherConfig, DitherPair}; +pub use downscale::{DownscaleConfig, DownscaleMode}; +pub use grid::Grid; +pub use options::{resolve_pipeline_config, PaletteSource, PipelineOptions}; pub use quantize::QuantizeConfig; +/// Blocks whose winning vote share falls below this are worth flagging to +/// the user — they usually indicate a misdetected pitch or phase. +pub const LOW_VOTE_SHARE: f32 = 0.6; + +/// Per-block winning-vote shares from grid normalization. +#[derive(Debug, Clone)] +pub struct BlockShareMask { + pub blocks_w: u32, + pub blocks_h: u32, + /// Row-major winner share per block, in [0, 1]. + pub shares: Vec, +} + +impl BlockShareMask { + /// Coordinates of blocks under the [`LOW_VOTE_SHARE`] threshold. + pub fn low_confidence_blocks(&self) -> Vec<(u32, u32)> { + self.shares + .iter() + .enumerate() + .filter(|(_, &s)| s < LOW_VOTE_SHARE) + .map(|(i, _)| (i as u32 % self.blocks_w, i as u32 / self.blocks_w)) + .collect() + } +} + +/// Render a diagnostic overlay: the source image dimmed, with each block +/// tinted red in proportion to how contested its color vote was (share +/// 1.0 = untouched, share at/below 0.5 = strongest tint). Makes a wrong +/// pitch or phase visible at a glance. +pub fn render_share_overlay(source: &RgbaImage, grid: &Grid, shares: &BlockShareMask) -> RgbaImage { + let mut out = source.clone(); + // Dim the base so the heat reads clearly. + for p in out.pixels_mut() { + p[0] = (p[0] as u16 * 55 / 100) as u8; + p[1] = (p[1] as u16 * 55 / 100) as u8; + p[2] = (p[2] as u16 * 55 / 100) as u8; + } + let edges_x = grid.edges_x(source.width()); + let edges_y = grid.edges_y(source.height()); + let bw = (edges_x.len() - 1).min(shares.blocks_w as usize); + let bh = (edges_y.len() - 1).min(shares.blocks_h as usize); + for by in 0..bh { + for bx in 0..bw { + let share = shares.shares[by * shares.blocks_w as usize + bx]; + // 0 at share >= 1.0, 1 at share <= 0.5. + let heat = ((1.0 - share) * 2.0).clamp(0.0, 1.0); + if heat <= 0.0 { + continue; + } + for y in edges_y[by]..edges_y[by + 1] { + for x in edges_x[bx]..edges_x[bx + 1] { + let p = out.get_pixel_mut(x, y); + let a = heat * 0.85; + p[0] = (p[0] as f32 * (1.0 - a) + 243.0 * a) as u8; + p[1] = (p[1] as f32 * (1.0 - a) + 139.0 * a) as u8; + p[2] = (p[2] as f32 * (1.0 - a) + 168.0 * a) as u8; + } + } + } + } + out +} + /// Configuration for the grid detection stage. #[derive(Debug, Clone)] pub struct GridDetectConfig { - /// If set, skip auto-detection and use this grid size. - pub override_size: Option, + /// If set, skip auto-detection and use this grid size (may be fractional, + /// e.g. 10.667 for a 48-cell sprite rendered at 512px). + pub override_size: Option, /// If set, skip auto-detection and use this grid phase offset. pub override_phase: Option<(u32, u32)>, - /// Maximum candidate grid size to test (default: 32). - pub max_candidate: u32, + /// Full programmatic grid override (used per-tile by sprite sheets); + /// takes priority over `override_size`/`override_phase`. + pub override_grid: Option, + /// Maximum candidate pitch to test. `None` scales with the image: + /// min(W,H)/4 clamped to [16, 96]. + pub max_candidate: Option, + /// Confidence floor below which detection declines to snap (the best + /// guess is still reported in diagnostics). + pub min_confidence: f32, + /// Multiply the final pitch (detected or from `override_size`) by this + /// integer: the detector finds the RENDER quantum (the grid the + /// generator physically painted on), while the intended ARTISTIC + /// resolution often sits at a multiple of it. Phase is preserved, so + /// coarse blocks stay aligned with the fine grid. 1 = off. Programmatic + /// `override_grid` values are used exactly as given. + pub coarsen: u32, /// Whether to skip grid detection entirely. pub skip: bool, } @@ -31,7 +115,10 @@ impl Default for GridDetectConfig { Self { override_size: None, override_phase: None, - max_candidate: 32, + override_grid: None, + max_candidate: None, + min_confidence: 0.35, + coarsen: 1, skip: false, } } @@ -44,7 +131,8 @@ pub struct PipelineConfig { pub aa: AaRemovalConfig, pub quantize: QuantizeConfig, pub background: BackgroundConfig, - pub downscale_mode: DownscaleMode, + pub downscale: DownscaleConfig, + pub dither: DitherConfig, /// Explicit output width. If None, preserves input width. pub output_width: Option, /// Explicit output height. If None, preserves input height. @@ -56,8 +144,25 @@ pub struct PipelineConfig { pub struct PipelineDiagnostics { /// Grid detection confidence (0.0 = no confidence, 1.0 = perfect grid). pub grid_confidence: Option, - /// Edge alignment scores for each candidate grid size (for debugging). - pub grid_variance_scores: Vec<(u32, f32)>, + /// Edge alignment score per candidate pitch (for debugging/UI). + pub grid_scores: Vec<(f32, f32)>, + /// Best grid guess, populated even when confidence fell below the floor + /// and the pipeline declined to snap. + pub grid_best_guess: Option, + /// Unique colors before and after quantization. + pub unique_colors_before: Option, + pub unique_colors_after: Option, + /// Pixels rewritten by AA removal, and passes actually run. + pub aa_pixels_changed: Option, + pub aa_passes_run: u32, + /// Per-block winning-vote shares from grid normalization. + pub block_vote_shares: Option, + /// Detected background color, border coverage, and pixels removed. + pub detected_bg: Option<[u8; 3]>, + pub bg_coverage: Option, + pub bg_pixels_removed: Option, + /// Detected dither pairs: (color a, color b, alternating block count). + pub dither_pairs: Vec<([u8; 3], [u8; 3], u32)>, } /// State carried through the pipeline stages. @@ -67,10 +172,14 @@ pub struct PipelineState { /// Original input dimensions (for preserving size). pub original_width: u32, pub original_height: u32, - /// Detected (or overridden) grid size. - pub grid_size: Option, - /// Detected (or overridden) grid phase offset (x, y). - pub grid_phase: Option<(u32, u32)>, + /// Detected (or overridden) pixel grid. + pub grid: Option, + /// Background mask (true = background), always sized to the CURRENT + /// `image` resolution. Built before grid normalization, applied after. + pub bg_mask: Option>, + /// Dither pairs detected post-normalization; pinned during + /// quantization so the pattern survives. + pub dither_pairs: Vec, /// Diagnostic info for reporting / TUI display. pub diagnostics: PipelineDiagnostics, } @@ -83,8 +192,9 @@ impl PipelineState { image, original_width: w, original_height: h, - grid_size: None, - grid_phase: None, + grid: None, + bg_mask: None, + dither_pairs: Vec::new(), diagnostics: PipelineDiagnostics::default(), } } @@ -94,9 +204,13 @@ impl PipelineState { pub fn run_pipeline(image: RgbaImage, config: &PipelineConfig) -> Result { let mut state = PipelineState::new(image); - // Stage 1: Grid detection + // Stage 1: Grid detection. Overrides apply even when detection is + // skipped — --no-grid-detect --grid-size N must still snap to the grid. if !config.grid.skip { grid_detect::detect_grid(&mut state, &config.grid)?; + } else if let Some(grid) = grid_detect::override_grid(&config.grid) { + grid.validate(state.image.width(), state.image.height())?; + state.grid = Some(grid); } // Stage 2: Anti-aliasing removal (before downscale for best results) @@ -104,37 +218,101 @@ pub fn run_pipeline(image: RgbaImage, config: &PipelineConfig) -> Result 1) - let used_snap_mode = config.downscale_mode == DownscaleMode::Snap; - if let Some(grid_size) = state.grid_size { - if grid_size > 1 { - downscale::majority_vote_downscale(&mut state, config.downscale_mode)?; + // Stage 3: Background detection + mask, at full resolution where the AA + // fringe is genuinely 1px wide. Nothing is cleared yet — the mask rides + // through grid normalization (masked pixels don't vote) and is applied + // afterwards, so fringes can't get baked into solid halo blocks. + // Chroma keys build their own global mask and combine with it. + let mut removal_mask: Option> = None; + if config.background.enabled { + if let Some(bg) = background::resolve_background(&state.image, &config.background) { + removal_mask = Some(background::build_bg_mask( + &state.image, + &bg, + &config.background, + )); + state.diagnostics.detected_bg = Some([bg.color[0], bg.color[1], bg.color[2]]); + state.diagnostics.bg_coverage = Some(bg.coverage); + } else { + info!("No dominant border color detected, skipping background removal"); } } + if !config.background.chroma_keys.is_empty() { + let chroma = background::build_chroma_mask( + &state.image, + &config.background.chroma_keys, + config.background.chroma_tolerance, + config.background.defringe, + ); + removal_mask = Some(match removal_mask { + Some(mut m) => { + for (a, b) in m.iter_mut().zip(chroma) { + *a |= b; + } + m + } + None => chroma, + }); + } + state.bg_mask = removal_mask; - // Stage 4: Color quantization (at logical pixel resolution — fast) + // Stage 4: Grid normalization (mask-aware) + let used_snap_mode = config.downscale.mode == DownscaleMode::Snap; + if state.grid.is_some() { + downscale::majority_vote_downscale(&mut state, &config.downscale)?; + } + + // Stage 5: Apply the background mask + if let Some(mask) = state.bg_mask.take() { + let removed = background::apply_bg_mask(&mut state.image, &mask); + state.diagnostics.bg_pixels_removed = Some(removed as u64); + info!(removed, "Background removal complete"); + } + + // Stage 6: Dither detection on the logical block grid — the pairs feed + // quantization as pinned centroids so the pattern can't be flattened. + if config.dither.enabled && !config.quantize.skip { + let grid_for_dither = if used_snap_mode { state.grid } else { None }; + state.dither_pairs = + dither::detect_dither_pairs(&state.image, grid_for_dither.as_ref(), &config.dither); + if !state.dither_pairs.is_empty() { + info!(pairs = state.dither_pairs.len(), "Detected dither pairs"); + } + state.diagnostics.dither_pairs = state + .dither_pairs + .iter() + .map(|p| (p.a_rgb, p.b_rgb, p.alternating)) + .collect(); + } + + // Stage 7: Color quantization (after background removal so background + // colors never consume palette slots) if !config.quantize.skip { quantize::quantize_colors(&mut state, &config.quantize)?; } - // Stage 5: Background removal - background::remove_background(&mut state.image, &config.background)?; - - // Stage 6: Resize to final output size - // Snap mode already outputs at original resolution, so only resize if - // the user explicitly requested different dimensions. - let out_w = if used_snap_mode { - config.output_width.unwrap_or(state.image.width()) - } else { - config.output_width.unwrap_or(state.original_width) - }; - let out_h = if used_snap_mode { - config.output_height.unwrap_or(state.image.height()) - } else { - config.output_height.unwrap_or(state.original_height) - }; - let (cur_w, cur_h) = (state.image.width(), state.image.height()); + // Stage 8: Output sizing. + // + // Snap mode is already at original resolution. Reduced-resolution modes + // hold the logical image here; the default is a crisp integer re-upscale + // by the rounded pitch (a 48-cell sprite detected at 10.667px comes out + // 528px, not Nearest-stretched back to 512 with uneven logical pixels — + // recreating that misalignment is exactly what this tool exists to + // remove). `logical_output` keeps the logical resolution instead. + if !used_snap_mode && !config.downscale.logical_output { + if let Some(grid) = state.grid { + let (fx, fy) = grid.upscale_factors(); + if fx > 1 || fy > 1 { + info!(fx, fy, "Integer re-upscale of logical image"); + state.image = integer_upscale(&state.image, fx, fy); + } + } + } + // Explicitly requested dimensions always win, as a final Nearest resize. + let out_w = config.output_width.unwrap_or(state.image.width()); + let out_h = config.output_height.unwrap_or(state.image.height()); + let (cur_w, cur_h) = (state.image.width(), state.image.height()); if cur_w != out_w || cur_h != out_h { info!( from_w = cur_w, @@ -153,3 +331,100 @@ pub fn run_pipeline(image: RgbaImage, config: &PipelineConfig) -> Result RgbaImage { + let (w, h) = (image.width(), image.height()); + let mut out = RgbaImage::new(w * fx, h * fy); + for y in 0..h { + for x in 0..w { + let p = *image.get_pixel(x, y); + for dy in 0..fy { + for dx in 0..fx { + out.put_pixel(x * fx + dx, y * fy + dy, p); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + fn quadrant_image(pitch: u32) -> RgbaImage { + let size = pitch * 2; + let mut img = RgbaImage::new(size, size); + for y in 0..size { + for x in 0..size { + let c = match (x < pitch, y < pitch) { + (true, true) => Rgba([255, 0, 0, 255]), + (false, true) => Rgba([0, 255, 0, 255]), + (true, false) => Rgba([0, 0, 255, 255]), + (false, false) => Rgba([255, 255, 0, 255]), + }; + img.put_pixel(x, y, c); + } + } + img + } + + fn base_config(mode: DownscaleMode) -> PipelineConfig { + PipelineConfig { + grid: GridDetectConfig { + override_size: Some(6.0), + override_phase: Some((0, 0)), + ..Default::default() + }, + downscale: DownscaleConfig { + mode, + ..Default::default() + }, + quantize: QuantizeConfig { + skip: true, + ..Default::default() + }, + aa: AaRemovalConfig { + skip: true, + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn test_reduced_mode_defaults_to_integer_reupscale() { + let state = + run_pipeline(quadrant_image(6), &base_config(DownscaleMode::MajorityVote)).unwrap(); + // 2x2 logical at pitch 6 -> 12x12 exact re-upscale, uniform blocks. + assert_eq!((state.image.width(), state.image.height()), (12, 12)); + assert_eq!(*state.image.get_pixel(0, 0), Rgba([255, 0, 0, 255])); + assert_eq!(*state.image.get_pixel(5, 5), Rgba([255, 0, 0, 255])); + assert_eq!(*state.image.get_pixel(6, 0), Rgba([0, 255, 0, 255])); + } + + #[test] + fn test_logical_output_keeps_logical_size() { + let mut config = base_config(DownscaleMode::MajorityVote); + config.downscale.logical_output = true; + let state = run_pipeline(quadrant_image(6), &config).unwrap(); + assert_eq!((state.image.width(), state.image.height()), (2, 2)); + } + + #[test] + fn test_snap_keeps_original_dims() { + let state = run_pipeline(quadrant_image(6), &base_config(DownscaleMode::Snap)).unwrap(); + assert_eq!((state.image.width(), state.image.height()), (12, 12)); + } + + #[test] + fn test_explicit_dims_win() { + let mut config = base_config(DownscaleMode::MajorityVote); + config.output_width = Some(24); + config.output_height = Some(24); + let state = run_pipeline(quadrant_image(6), &config).unwrap(); + assert_eq!((state.image.width(), state.image.height()), (24, 24)); + } +} diff --git a/src/pipeline/options.rs b/src/pipeline/options.rs new file mode 100644 index 0000000..ee42bf8 --- /dev/null +++ b/src/pipeline/options.rs @@ -0,0 +1,448 @@ +//! Single source of truth for assembling a [`PipelineConfig`] from any +//! front end (CLI flags, config file, pixfix, TUI). +//! +//! Every front end lowers into [`PipelineOptions`] (all-`Option` plain data, +//! `None` = "not specified"), layers are merged with [`PipelineOptions::overlay`] +//! (higher precedence first), and [`PipelineOptions::into_config`] applies the +//! hard defaults and validation exactly once. Precedence is CLI > config file +//! > defaults. + +use crate::error::{NormalizeError, Result}; +use crate::pipeline::{ + AaRemovalConfig, BackgroundConfig, DownscaleConfig, DownscaleMode, GridDetectConfig, + PipelineConfig, QuantizeConfig, +}; + +/// Which palette the quantize stage should use. +/// +/// Modeled as one enum so a CLI `--colors 16` cleanly overrides a config-file +/// `palette = "pico-8"` — a field-wise merge would keep both and mis-prioritize. +#[derive(Debug, Clone, PartialEq)] +pub enum PaletteSource { + /// A built-in palette by slug (e.g. "pico-8"). + Named(String), + /// An explicit color list (from a .hex file or Lospec fetch). + Custom { + colors: Vec<[u8; 3]>, + /// Provenance for reporting, e.g. "file:pal.hex" or "lospec:sweetie-16". + label: Option, + }, + /// Auto-extract this many colors via k-means. + AutoExtract(u32), +} + +impl PaletteSource { + /// Short human/JSON label for reports. + pub fn label(&self) -> String { + match self { + PaletteSource::Named(name) => name.clone(), + PaletteSource::Custom { label, colors } => label + .clone() + .unwrap_or_else(|| format!("custom:{}", colors.len())), + PaletteSource::AutoExtract(n) => format!("auto:{}", n), + } + } +} + +/// All-`Option` pipeline settings. `None` means "not specified at this layer". +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PipelineOptions { + // Grid detection + pub grid_size: Option, + pub grid_phase: Option<(u32, u32)>, + pub max_grid_candidate: Option, + pub no_grid_detect: Option, + pub coarsen: Option, + pub min_confidence: Option, + // Downscale + pub downscale_mode: Option, + pub keep_alpha: Option, + pub logical_output: Option, + // Anti-aliasing + pub aa_threshold: Option, + pub aa_skip: Option, + pub aa_passes: Option, + // Quantization + pub palette: Option, + pub no_quantize: Option, + pub seed: Option, + // Dithering + pub flatten_dither: Option, + // Background removal + pub bg_enabled: Option, + pub bg_color: Option<[u8; 3]>, + pub bg_border_threshold: Option, + pub bg_color_tolerance: Option, + pub bg_flood_fill: Option, + pub chroma_keys: Option>, + pub chroma_tolerance: Option, + // Output size + pub output_width: Option, + pub output_height: Option, +} + +impl PipelineOptions { + /// Layer `self` (higher precedence) over `lower`, field by field. + pub fn overlay(self, lower: PipelineOptions) -> PipelineOptions { + PipelineOptions { + grid_size: self.grid_size.or(lower.grid_size), + grid_phase: self.grid_phase.or(lower.grid_phase), + max_grid_candidate: self.max_grid_candidate.or(lower.max_grid_candidate), + no_grid_detect: self.no_grid_detect.or(lower.no_grid_detect), + coarsen: self.coarsen.or(lower.coarsen), + min_confidence: self.min_confidence.or(lower.min_confidence), + downscale_mode: self.downscale_mode.or(lower.downscale_mode), + keep_alpha: self.keep_alpha.or(lower.keep_alpha), + logical_output: self.logical_output.or(lower.logical_output), + aa_threshold: self.aa_threshold.or(lower.aa_threshold), + aa_skip: self.aa_skip.or(lower.aa_skip), + aa_passes: self.aa_passes.or(lower.aa_passes), + palette: self.palette.or(lower.palette), + no_quantize: self.no_quantize.or(lower.no_quantize), + seed: self.seed.or(lower.seed), + flatten_dither: self.flatten_dither.or(lower.flatten_dither), + bg_enabled: self.bg_enabled.or(lower.bg_enabled), + bg_color: self.bg_color.or(lower.bg_color), + bg_border_threshold: self.bg_border_threshold.or(lower.bg_border_threshold), + bg_color_tolerance: self.bg_color_tolerance.or(lower.bg_color_tolerance), + bg_flood_fill: self.bg_flood_fill.or(lower.bg_flood_fill), + chroma_keys: self.chroma_keys.or(lower.chroma_keys), + chroma_tolerance: self.chroma_tolerance.or(lower.chroma_tolerance), + output_width: self.output_width.or(lower.output_width), + output_height: self.output_height.or(lower.output_height), + } + } + + /// Apply hard defaults, validate, and produce the concrete config. + /// + /// All pipeline defaults live here and nowhere else. + pub fn into_config(self) -> Result { + if let Some(size) = self.grid_size { + if !size.is_finite() || size < 2.0 { + return Err(NormalizeError::InvalidGridSize(size)); + } + if let Some((px, py)) = self.grid_phase { + if px as f32 >= size || py as f32 >= size { + return Err(NormalizeError::InvalidPhase { x: px, y: py }); + } + } + } else if self.grid_phase.is_some() { + return Err(NormalizeError::Config( + "--grid-phase requires --grid-size".to_string(), + )); + } + + if let Some(c) = self.coarsen { + if c < 1 { + return Err(NormalizeError::Config( + "--coarsen must be at least 1".to_string(), + )); + } + } + + if let Some(m) = self.min_confidence { + if !(0.0..=1.0).contains(&m) { + return Err(NormalizeError::Config(format!( + "--min-confidence must be between 0.0 and 1.0, got {}", + m + ))); + } + } + + let skip_detect = self.no_grid_detect.unwrap_or(false); + if skip_detect && self.grid_size.is_none() { + return Err(NormalizeError::Config( + "--no-grid-detect requires --grid-size".to_string(), + )); + } + + if let Some(t) = self.aa_threshold { + if !(0.0..=1.0).contains(&t) { + return Err(NormalizeError::Config(format!( + "AA threshold must be between 0.0 and 1.0, got {}", + t + ))); + } + } + + if let Some(PaletteSource::AutoExtract(n)) = self.palette { + if n < 2 { + return Err(NormalizeError::Palette(format!( + "number of colors must be >= 2, got {}", + n + ))); + } + } + + let (num_colors, palette_name, custom_palette) = match self.palette { + Some(PaletteSource::Named(name)) => (None, Some(name), None), + Some(PaletteSource::Custom { colors, .. }) => (None, None, Some(colors)), + Some(PaletteSource::AutoExtract(n)) => (Some(n), None, None), + None => (None, None, None), + }; + + let flatten_dither = self.flatten_dither.unwrap_or(false); + Ok(PipelineConfig { + grid: GridDetectConfig { + override_size: self.grid_size, + override_phase: self.grid_phase, + override_grid: None, + max_candidate: self.max_grid_candidate, + min_confidence: self.min_confidence.unwrap_or(0.35), + coarsen: self.coarsen.unwrap_or(1), + skip: skip_detect, + }, + aa: AaRemovalConfig { + threshold: self.aa_threshold.unwrap_or(0.5), + max_passes: self.aa_passes.unwrap_or(3), + protect_dither: !flatten_dither, + // AA is opt-in: enabled only when a threshold was specified + // somewhere, unless explicitly skipped. + skip: self.aa_skip.unwrap_or(self.aa_threshold.is_none()), + }, + quantize: QuantizeConfig { + num_colors, + palette_name, + custom_palette, + seed: self.seed.unwrap_or(0), + skip: self.no_quantize.unwrap_or(false), + ..Default::default() + }, + dither: crate::pipeline::DitherConfig { + enabled: !flatten_dither, + ..Default::default() + }, + background: BackgroundConfig { + enabled: self.bg_enabled.unwrap_or(false), + bg_color: self.bg_color, + border_threshold: self.bg_border_threshold.unwrap_or(0.4), + color_tolerance: self.bg_color_tolerance.unwrap_or(0.05), + flood_fill: self.bg_flood_fill.unwrap_or(true), + defringe: true, + chroma_keys: self.chroma_keys.unwrap_or_default(), + chroma_tolerance: self.chroma_tolerance.unwrap_or(0.05), + }, + downscale: DownscaleConfig { + mode: self.downscale_mode.unwrap_or_default(), + keep_alpha: self.keep_alpha.unwrap_or(false), + logical_output: self.logical_output.unwrap_or(false), + }, + output_width: self.output_width, + output_height: self.output_height, + }) + } +} + +/// Convenience wrapper: CLI layer over config-file layer over defaults. +pub fn resolve_pipeline_config( + cli: PipelineOptions, + file: PipelineOptions, +) -> Result { + cli.overlay(file).into_config() +} + +impl crate::config::ConfigFile { + /// Lower the config file into pipeline options. + pub fn to_options(&self) -> Result { + let grid_phase = match (self.grid.phase_x, self.grid.phase_y) { + (Some(x), Some(y)) => Some((x, y)), + (None, None) => None, + _ => { + return Err(NormalizeError::Config( + "config file must set both grid.phase_x and grid.phase_y or neither" + .to_string(), + )) + } + }; + + let bg_color = match self.background.color { + Some(ref hex) => Some(crate::config::parse_hex_color(hex).map_err(|e| { + NormalizeError::Config(format!("config file background.color: {}", e)) + })?), + None => None, + }; + + let palette = self + .quantize + .palette + .clone() + .map(PaletteSource::Named) + .or(self.quantize.colors.map(PaletteSource::AutoExtract)); + + let chroma_keys_parsed = match self.background.chroma_keys { + Some(ref keys) => { + let mut parsed = Vec::with_capacity(keys.len()); + for hex in keys { + parsed.push(crate::config::parse_hex_color(hex).map_err(|e| { + NormalizeError::Config(format!("config file background.chroma_keys: {}", e)) + })?); + } + Some(parsed) + } + None => None, + }; + + Ok(PipelineOptions { + grid_size: self.grid.size, + grid_phase, + max_grid_candidate: self.grid.max_candidate, + no_grid_detect: self.grid.skip, + coarsen: self.grid.coarsen, + min_confidence: self.grid.min_confidence, + downscale_mode: None, + keep_alpha: None, + logical_output: None, + aa_threshold: self.aa.threshold, + // A threshold in the file implies AA on (unless the file also says skip). + aa_skip: self.aa.skip.or(self.aa.threshold.map(|_| false)), + aa_passes: None, + palette, + no_quantize: self.quantize.skip, + seed: None, + flatten_dither: None, + bg_enabled: self.background.enabled, + bg_color, + bg_border_threshold: self.background.border_threshold, + bg_color_tolerance: self.background.color_tolerance, + bg_flood_fill: self.background.flood_fill, + chroma_keys: chroma_keys_parsed, + chroma_tolerance: self.background.chroma_tolerance, + output_width: None, + output_height: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file_options(toml_str: &str) -> PipelineOptions { + let file: crate::config::ConfigFile = toml::from_str(toml_str).unwrap(); + file.to_options().unwrap() + } + + #[test] + fn cli_beats_file_for_max_candidate() { + // Regression: the old build_pipeline_config let the config file win. + let cli = PipelineOptions { + max_grid_candidate: Some(16), + ..Default::default() + }; + let file = file_options("[grid]\nmax_candidate = 64\n"); + let config = resolve_pipeline_config(cli, file).unwrap(); + assert_eq!(config.grid.max_candidate, Some(16)); + } + + #[test] + fn file_beats_default() { + let file = file_options("[grid]\nmax_candidate = 64\n"); + let config = resolve_pipeline_config(PipelineOptions::default(), file).unwrap(); + assert_eq!(config.grid.max_candidate, Some(64)); + } + + #[test] + fn defaults_applied_when_unset() { + let config = PipelineOptions::default().into_config().unwrap(); + assert_eq!(config.grid.max_candidate, None); + assert!(config.aa.skip); + assert!(!config.background.enabled); + assert!(config.background.flood_fill); + assert_eq!(config.downscale.mode, DownscaleMode::Snap); + } + + #[test] + fn cli_colors_beats_file_palette() { + let cli = PipelineOptions { + palette: Some(PaletteSource::AutoExtract(16)), + ..Default::default() + }; + let file = file_options("[quantize]\npalette = \"pico-8\"\n"); + let config = resolve_pipeline_config(cli, file).unwrap(); + assert_eq!(config.quantize.num_colors, Some(16)); + assert_eq!(config.quantize.palette_name, None); + } + + #[test] + fn cli_bool_false_beats_file_true() { + // --no-remove-bg must be able to override a config file's enabled=true. + let cli = PipelineOptions { + bg_enabled: Some(false), + ..Default::default() + }; + let file = file_options("[background]\nenabled = true\n"); + let config = resolve_pipeline_config(cli, file).unwrap(); + assert!(!config.background.enabled); + } + + #[test] + fn file_phase_requires_both_axes() { + let file: crate::config::ConfigFile = toml::from_str("[grid]\nphase_x = 2\n").unwrap(); + assert!(file.to_options().is_err()); + } + + #[test] + fn file_phase_is_wired() { + // Regression: grid.phase_x/phase_y used to be parsed but never read. + let file = file_options("[grid]\nsize = 8\nphase_x = 2\nphase_y = 3\n"); + let config = resolve_pipeline_config(PipelineOptions::default(), file).unwrap(); + assert_eq!(config.grid.override_size, Some(8.0)); + assert_eq!(config.grid.override_phase, Some((2, 3))); + } + + #[test] + fn aa_threshold_enables_aa() { + let cli = PipelineOptions { + aa_threshold: Some(0.3), + aa_skip: Some(false), + ..Default::default() + }; + let config = cli.into_config().unwrap(); + assert!(!config.aa.skip); + assert!((config.aa.threshold - 0.3).abs() < f32::EPSILON); + } + + #[test] + fn file_aa_threshold_enables_aa() { + let file = file_options("[aa]\nthreshold = 0.4\n"); + let config = resolve_pipeline_config(PipelineOptions::default(), file).unwrap(); + assert!(!config.aa.skip); + } + + #[test] + fn validation_rejects_bad_grid() { + let bad_size = PipelineOptions { + grid_size: Some(0.0), + ..Default::default() + }; + assert!(matches!( + bad_size.into_config(), + Err(NormalizeError::InvalidGridSize(s)) if s == 0.0 + )); + + let bad_phase = PipelineOptions { + grid_size: Some(4.0), + grid_phase: Some((4, 0)), + ..Default::default() + }; + assert!(matches!( + bad_phase.into_config(), + Err(NormalizeError::InvalidPhase { .. }) + )); + + let skip_without_size = PipelineOptions { + no_grid_detect: Some(true), + ..Default::default() + }; + assert!(skip_without_size.into_config().is_err()); + } + + #[test] + fn validation_rejects_bad_aa_threshold() { + let opts = PipelineOptions { + aa_threshold: Some(1.5), + ..Default::default() + }; + assert!(opts.into_config().is_err()); + } +} diff --git a/src/pipeline/quantize.rs b/src/pipeline/quantize.rs index 1e394dc..89b1959 100644 --- a/src/pipeline/quantize.rs +++ b/src/pipeline/quantize.rs @@ -1,16 +1,28 @@ -use anyhow::{bail, Result}; +use crate::error::{NormalizeError, Result}; use image::Rgba; use palette::Oklab; use tracing::info; -use crate::color::kmeans::{kmeans_oklab, subsample}; +use crate::color::kmeans::{kmeans_weighted, weighted_medoids, KmeansConfig}; use crate::color::oklab::rgba_to_oklab; -use crate::color::palette_match::{palette_to_oklab, palette_to_rgba, snap_to_palette}; +use crate::color::palette_match::{palette_to_oklab, palette_to_rgba, PaletteMatcher}; use crate::color::palettes; +use crate::image_util::histogram::ColorHistogram; use crate::pipeline::PipelineState; +/// How auto-extracted palette entries are derived from their clusters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PaletteCentroid { + /// The most representative *actual* color of each cluster (weighted + /// medoid). Never invents averaged colors — pixel art wants real ones. + #[default] + Medoid, + /// The cluster's weighted OKLAB mean, converted back to sRGB. + Mean, +} + /// Configuration for color quantization. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct QuantizeConfig { /// Number of colors to extract (for auto-extract mode). pub num_colors: Option, @@ -18,23 +30,27 @@ pub struct QuantizeConfig { pub palette_name: Option, /// Custom palette as RGB triplets. pub custom_palette: Option>, - /// Maximum pixel samples for k-means (default: 10000). - pub max_samples: usize, - /// Maximum k-means iterations (default: 50). + /// Maximum k-means iterations. pub max_iterations: usize, + /// RNG seed — the only source of randomness in quantization. + pub seed: u64, + /// How auto-extracted palette entries are derived. + pub centroid_mode: PaletteCentroid, /// Whether to skip quantization entirely. pub skip: bool, } -impl QuantizeConfig { - pub fn with_defaults(mut self) -> Self { - if self.max_samples == 0 { - self.max_samples = 10000; - } - if self.max_iterations == 0 { - self.max_iterations = 50; +impl Default for QuantizeConfig { + fn default() -> Self { + Self { + num_colors: None, + palette_name: None, + custom_palette: None, + max_iterations: 50, + seed: 0, + centroid_mode: PaletteCentroid::default(), + skip: false, } - self } } @@ -43,111 +59,206 @@ impl QuantizeConfig { /// Three modes: /// 1. Predefined palette: snap all pixels to the named palette /// 2. Custom palette: snap all pixels to the provided colors -/// 3. Auto-extract: run k-means in OKLAB to find N colors, then snap +/// 3. Auto-extract: weighted k-means over unique colors in OKLAB, then snap pub fn quantize_colors(state: &mut PipelineState, config: &QuantizeConfig) -> Result<()> { if config.skip { return Ok(()); } - let config = fill_defaults(config); + let histogram = ColorHistogram::from_image(&state.image); + let unique_before = histogram.unique_colors(); // Determine the target palette - let (palette_oklab, palette_rgba) = if let Some(ref name) = config.palette_name { + let Some((palette_oklab, palette_rgba)) = + resolve_palette(&histogram, config, &state.dither_pairs)? + else { + // No quantization mode specified + return Ok(()); + }; + + // Snap all pixels to the palette + let mut matcher = PaletteMatcher::from_parts(palette_oklab, palette_rgba); + warn_collapsed_dither_pairs(&mut matcher, &state.dither_pairs); + matcher.snap_image(&mut state.image); + + let unique_after = ColorHistogram::from_image(&state.image).unique_colors(); + state.diagnostics.unique_colors_before = Some(unique_before as u32); + state.diagnostics.unique_colors_after = Some(unique_after as u32); + info!(colors = unique_after, "Quantization complete"); + + Ok(()) +} + +/// A resolved palette: parallel OKLAB and RGBA entries. +pub(crate) type ResolvedPalette = (Vec, Vec>); + +/// Resolve the target palette for the configured mode, or `None` when no +/// quantization mode is set. `histogram` only feeds auto-extraction, so the +/// animated path can pass a histogram merged across all frames and share +/// the result. +pub(crate) fn resolve_palette( + histogram: &ColorHistogram, + config: &QuantizeConfig, + dither_pairs: &[crate::pipeline::DitherPair], +) -> Result> { + if let Some(ref name) = config.palette_name { // Mode 1: Predefined palette - let pal = palettes::find_palette(name) - .ok_or_else(|| { - let available: Vec<&str> = palettes::ALL_PALETTES.iter().map(|p| p.slug).collect(); - anyhow::anyhow!( - "Unknown palette '{}'. Available: {}", - name, - available.join(", ") - ) - })?; - info!(palette = pal.name, colors = pal.colors.len(), "Using predefined palette"); - (palette_to_oklab(pal.colors), palette_to_rgba(pal.colors)) + let pal = palettes::find_palette(name).ok_or_else(|| { + let available: Vec<&str> = palettes::ALL_PALETTES.iter().map(|p| p.slug).collect(); + NormalizeError::Palette(format!( + "unknown palette '{}'. Available: {}", + name, + available.join(", ") + )) + })?; + info!( + palette = pal.name, + colors = pal.colors.len(), + "Using predefined palette" + ); + Ok(Some(( + palette_to_oklab(pal.colors), + palette_to_rgba(pal.colors), + ))) } else if let Some(ref custom) = config.custom_palette { // Mode 2: Custom palette info!(colors = custom.len(), "Using custom palette"); - (palette_to_oklab(custom), palette_to_rgba(custom)) + Ok(Some((palette_to_oklab(custom), palette_to_rgba(custom)))) } else if let Some(num) = config.num_colors { - // Mode 3: Auto-extract via k-means + // Mode 3: Auto-extract via weighted k-means if num < 2 { - bail!("Number of colors must be >= 2, got {}", num); + return Err(NormalizeError::Palette(format!( + "number of colors must be >= 2, got {}", + num + ))); } info!(target_colors = num, "Auto-extracting palette via k-means"); - extract_palette(state, num as usize, config.max_samples, config.max_iterations)? + extract_palette(histogram, num as usize, config, dither_pairs).map(Some) } else { - // No quantization mode specified - return Ok(()); - }; - - // Snap all pixels to the palette - snap_to_palette(&mut state.image, &palette_oklab, &palette_rgba); - - // Count unique colors in output - let mut unique_colors = std::collections::HashSet::new(); - for pixel in state.image.pixels() { - if pixel[3] > 0 { - unique_colors.insert(pixel.0); - } + Ok(None) } - info!(colors = unique_colors.len(), "Quantization complete"); - - Ok(()) } -fn fill_defaults(config: &QuantizeConfig) -> QuantizeConfig { - let mut c = config.clone(); - if c.max_samples == 0 { - c.max_samples = 10000; - } - if c.max_iterations == 0 { - c.max_iterations = 50; +/// With a fixed palette, dither pairs can't be pinned — warn when a +/// detected pair collapses onto a single palette entry. +pub(crate) fn warn_collapsed_dither_pairs( + matcher: &mut PaletteMatcher, + dither_pairs: &[crate::pipeline::DitherPair], +) { + for pair in dither_pairs { + if matcher.nearest(pair.a_rgb).0 == matcher.nearest(pair.b_rgb).0 { + tracing::warn!( + a = ?pair.a_rgb, + b = ?pair.b_rgb, + "dither pair collapses to one palette color; the pattern will flatten" + ); + } } - c } -/// Extract a palette from the image using k-means in OKLAB space. +/// Extract a palette from the image's color histogram using weighted +/// k-means over unique colors in OKLAB space. +/// +/// Clustering the histogram is exact (no subsampling), fast, and — because +/// weights enter both the k-means++ init and the centroid means — rare but +/// structural colors like 1px outlines keep their influence. fn extract_palette( - state: &PipelineState, + histogram: &ColorHistogram, num_colors: usize, - max_samples: usize, - max_iterations: usize, -) -> Result<(Vec, Vec>)> { - // Collect all opaque pixel colors in OKLAB - let all_colors: Vec = state - .image - .pixels() - .filter(|p| p[3] > 0) - .map(|p| rgba_to_oklab(*p)) + config: &QuantizeConfig, + dither_pairs: &[crate::pipeline::DitherPair], +) -> Result { + let entries = histogram.opaque_rgb_counts(); + if entries.is_empty() { + return Err(NormalizeError::Palette( + "no opaque pixels to extract palette from".to_string(), + )); + } + + let points: Vec = entries + .iter() + .map(|&(rgb, _)| rgba_to_oklab(Rgba([rgb[0], rgb[1], rgb[2], 255]))) .collect(); + let weights: Vec = entries.iter().map(|&(_, count)| count).collect(); - if all_colors.is_empty() { - bail!("No opaque pixels to extract palette from"); + // Pin detected dither-pair colors as fixed centroids so k-means cannot + // merge the partners; cap so pins never crowd out the requested budget. + let usable_pairs = dither_pairs.iter().take(num_colors / 2); + let mut pinned_oklab: Vec = Vec::new(); + let mut pinned_rgb: Vec<[u8; 3]> = Vec::new(); + for pair in usable_pairs { + for (rgb, ok) in [(pair.a_rgb, pair.a_oklab), (pair.b_rgb, pair.b_oklab)] { + if !pinned_rgb.contains(&rgb) { + pinned_rgb.push(rgb); + pinned_oklab.push(ok); + } + } + } + if !pinned_rgb.is_empty() { + info!(pinned = pinned_rgb.len(), "Pinning dither-pair colors"); } - // Subsample for speed - let samples = subsample(&all_colors, max_samples); + let result = kmeans_weighted( + &points, + &weights, + &pinned_oklab, + &KmeansConfig { + k: num_colors, + max_iterations: config.max_iterations, + seed: config.seed, + }, + ); + let k = result.centroids.len(); + let n_pinned = pinned_oklab.len().min(k); - // Run k-means - let centroids = kmeans_oklab(&samples, num_colors, max_iterations); + let (palette_oklab, palette_rgba) = match config.centroid_mode { + PaletteCentroid::Medoid => { + // Pinned entries keep their exact pair colors; free clusters + // take their weighted medoid — the exact RGB comes from the + // histogram entry, no OKLAB->sRGB roundtrip. + let medoids = weighted_medoids(&points, &weights, &result.assignments, k); + let mut oklabs: Vec = pinned_oklab.clone(); + let mut rgbas: Vec> = pinned_rgb + .iter() + .map(|rgb| Rgba([rgb[0], rgb[1], rgb[2], 255])) + .collect(); + for (c, m) in medoids.into_iter().enumerate().skip(n_pinned) { + if let Some(m) = m { + let rgb = entries[m].0; + oklabs.push(points[m]); + rgbas.push(Rgba([rgb[0], rgb[1], rgb[2], 255])); + } else { + let _ = c; + } + } + (oklabs, rgbas) + } + PaletteCentroid::Mean => { + let rgbas: Vec> = result + .centroids + .iter() + .map(|c| { + use palette::{IntoColor, Srgb}; + let srgb: Srgb = (*c).into_color(); + Rgba([ + (srgb.red.clamp(0.0, 1.0) * 255.0).round() as u8, + (srgb.green.clamp(0.0, 1.0) * 255.0).round() as u8, + (srgb.blue.clamp(0.0, 1.0) * 255.0).round() as u8, + 255, + ]) + }) + .collect(); + (result.centroids, rgbas) + } + }; - // Convert centroids back to RGBA - let palette_rgba: Vec> = centroids - .iter() - .map(|c| { - use palette::{IntoColor, Srgb}; - let srgb: Srgb = (*c).into_color(); - Rgba([ - (srgb.red.clamp(0.0, 1.0) * 255.0).round() as u8, - (srgb.green.clamp(0.0, 1.0) * 255.0).round() as u8, - (srgb.blue.clamp(0.0, 1.0) * 255.0).round() as u8, - 255, - ]) - }) - .collect(); + if palette_oklab.is_empty() { + return Err(NormalizeError::Palette( + "palette extraction produced no colors".to_string(), + )); + } - Ok((centroids, palette_rgba)) + Ok((palette_oklab, palette_rgba)) } #[cfg(test)] @@ -167,7 +278,6 @@ mod tests { let mut state = PipelineState::new(img); let config = QuantizeConfig { palette_name: Some("pico-8".to_string()), - skip: false, ..Default::default() }; quantize_colors(&mut state, &config).unwrap(); @@ -175,9 +285,14 @@ mod tests { // All pixels should now be exact PICO-8 colors let p0 = *state.image.get_pixel(0, 0); let p2 = *state.image.get_pixel(2, 0); - // Should be snapped to nearest PICO-8 color - assert!(palettes::PICO_8.colors.iter().any(|c| c[0] == p0[0] && c[1] == p0[1] && c[2] == p0[2])); - assert!(palettes::PICO_8.colors.iter().any(|c| c[0] == p2[0] && c[1] == p2[1] && c[2] == p2[2])); + assert!(palettes::PICO_8 + .colors + .iter() + .any(|c| c[0] == p0[0] && c[1] == p0[1] && c[2] == p0[2])); + assert!(palettes::PICO_8 + .colors + .iter() + .any(|c| c[0] == p2[0] && c[1] == p2[1] && c[2] == p2[2])); } #[test] @@ -198,7 +313,6 @@ mod tests { let mut state = PipelineState::new(img); let config = QuantizeConfig { num_colors: Some(2), - skip: false, ..Default::default() }; quantize_colors(&mut state, &config).unwrap(); @@ -209,6 +323,70 @@ mod tests { unique.insert(pixel.0); } assert!(unique.len() <= 2); + assert_eq!(state.diagnostics.unique_colors_after, Some(2)); + } + + #[test] + fn test_medoid_palette_uses_real_colors() { + // Two clusters of near-identical reds and blues: the extracted + // palette must consist of colors that exist in the image, not + // invented averages. + let mut img = RgbaImage::new(8, 1); + let colors = [ + [200u8, 10, 10], + [202, 12, 8], + [198, 8, 12], + [201, 11, 9], + [10, 10, 200], + [8, 12, 202], + [12, 8, 198], + [11, 9, 201], + ]; + for (i, c) in colors.iter().enumerate() { + img.put_pixel(i as u32, 0, Rgba([c[0], c[1], c[2], 255])); + } + + let mut state = PipelineState::new(img); + let config = QuantizeConfig { + num_colors: Some(2), + ..Default::default() + }; + quantize_colors(&mut state, &config).unwrap(); + + for pixel in state.image.pixels() { + let rgb = [pixel[0], pixel[1], pixel[2]]; + assert!( + colors.contains(&rgb), + "quantized color {:?} is not an original image color", + rgb + ); + } + } + + #[test] + fn test_deterministic_across_runs() { + let mut img = RgbaImage::new(16, 16); + let mut seed = 42u32; + for p in img.pixels_mut() { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + *p = Rgba([ + (seed >> 8) as u8, + (seed >> 16) as u8, + (seed >> 24) as u8, + 255, + ]); + } + + let run = |img: &RgbaImage| { + let mut state = PipelineState::new(img.clone()); + let config = QuantizeConfig { + num_colors: Some(8), + ..Default::default() + }; + quantize_colors(&mut state, &config).unwrap(); + state.image + }; + assert_eq!(run(&img), run(&img), "same seed must give identical bytes"); } #[test] diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..144ffa8 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,332 @@ +//! Human/JSON output routing and the machine-readable report types. +//! +//! Contract for agents: with `--json`, exactly one JSON document goes to +//! stdout; progress and logs go to stderr (NDJSON progress events in json +//! mode). Fields are always present — no `skip_serializing_if` — so the +//! schema is stable and nulls are explicit. + +use serde::Serialize; + +use crate::pipeline::{Grid, PipelineState, LOW_VOTE_SHARE}; + +/// Where human-facing chatter goes, and whether it goes at all. +enum Mode { + Human { quiet: bool }, + Json, +} + +/// Routes all CLI output. Every user-visible line the binary prints flows +/// through here so --quiet and --json actually mean what they say. +pub struct Reporter { + mode: Mode, +} + +impl Reporter { + pub fn new(json: bool, quiet: bool) -> Self { + Self { + mode: if json { + Mode::Json + } else { + Mode::Human { quiet } + }, + } + } + + pub fn is_json(&self) -> bool { + matches!(self.mode, Mode::Json) + } + + pub fn is_quiet(&self) -> bool { + matches!(self.mode, Mode::Human { quiet: true }) + } + + /// Human-facing status line (stderr). Suppressed by --quiet and --json. + pub fn info(&self, msg: impl std::fmt::Display) { + if let Mode::Human { quiet: false } = self.mode { + eprintln!("{}", msg); + } + } + + /// Error line (stderr). Never suppressed. + pub fn error(&self, err: impl std::fmt::Display) { + match self.mode { + Mode::Json => { + let doc = serde_json::json!({ "error": err.to_string() }); + eprintln!("{}", doc); + } + Mode::Human { .. } => eprintln!("Error: {}", err), + } + } + + /// Emit the single result document to stdout (json mode only). + pub fn emit(&self, report: &T) { + if self.is_json() { + match serde_json::to_string_pretty(report) { + Ok(s) => println!("{}", s), + Err(e) => eprintln!("{}", serde_json::json!({ "error": e.to_string() })), + } + } + } + + /// Emit an NDJSON progress event to stderr (json mode only). + pub fn progress(&self, event: &ProgressEvent) { + if self.is_json() { + if let Ok(s) = serde_json::to_string(event) { + eprintln!("{}", s); + } + } + } +} + +/// The detected (or overridden) grid, as reported. +#[derive(Debug, Serialize)] +pub struct GridReport { + pub pitch_x: f32, + pub pitch_y: f32, + pub phase: [f32; 2], + pub confidence: Option, + pub source: GridSource, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GridSource { + Detected, + Override, +} + +impl GridReport { + pub fn from_grid(grid: &Grid, confidence: Option, overridden: bool) -> Self { + let (px, py) = grid.display_phase(); + Self { + pitch_x: grid.pitch_x, + pitch_y: grid.pitch_y, + phase: [px, py], + confidence, + source: if overridden { + GridSource::Override + } else { + GridSource::Detected + }, + } + } +} + +/// Result document for `process` (and the base of `sheet`). +#[derive(Debug, Serialize)] +pub struct ProcessReport { + pub input: String, + pub output: String, + pub grid: Option, + /// Best guess when detection declined to snap (grid is null then). + pub grid_best_guess: Option, + pub logical_size: Option<[u32; 2]>, + pub output_size: [u32; 2], + /// Frame count: 1 for still images, the animation's frame count for + /// animated GIF inputs. + pub frames: u32, + pub palette: Option, + pub colors_before: Option, + pub colors_after: Option, + pub aa_pixels_changed: Option, + pub aa_passes: u32, + pub bg_removed: bool, + pub bg_color: Option, + pub bg_pixels_removed: Option, + pub dither_pairs: Vec, + pub low_confidence_blocks: u32, + pub total_blocks: u32, + pub warnings: Vec, + pub duration_ms: u64, +} + +#[derive(Debug, Serialize)] +pub struct DitherPairReport { + pub color_a: String, + pub color_b: String, + pub alternating_blocks: u32, +} + +impl ProcessReport { + /// Assemble the report from pipeline state. + pub fn from_state( + state: &PipelineState, + input: String, + output: String, + palette: Option, + overridden_grid: bool, + duration_ms: u64, + ) -> Self { + let d = &state.diagnostics; + let mut warnings = Vec::new(); + + let grid = state + .grid + .map(|g| GridReport::from_grid(&g, d.grid_confidence, overridden_grid)); + let grid_best_guess = if state.grid.is_none() { + d.grid_best_guess.map(|g| { + warnings.push( + "grid detection confidence below floor; image was not snapped (pass --grid-size to force)" + .to_string(), + ); + GridReport::from_grid(&g, d.grid_confidence, false) + }) + } else { + None + }; + + let logical_size = state.grid.map(|g| { + let (w, h) = g.logical_size(state.original_width, state.original_height); + [w, h] + }); + + let (low, total) = d + .block_vote_shares + .as_ref() + .map(|m| { + ( + m.low_confidence_blocks().len() as u32, + m.shares.len() as u32, + ) + }) + .unwrap_or((0, 0)); + if total > 0 && low > 0 { + warnings.push(format!( + "{} of {} blocks had contested votes (winner share < {:.0}%)", + low, + total, + LOW_VOTE_SHARE * 100.0 + )); + } + + Self { + input, + output, + grid, + grid_best_guess, + logical_size, + output_size: [state.image.width(), state.image.height()], + frames: 1, + palette, + colors_before: d.unique_colors_before, + colors_after: d.unique_colors_after, + aa_pixels_changed: d.aa_pixels_changed, + aa_passes: d.aa_passes_run, + bg_removed: d.bg_pixels_removed.is_some(), + bg_color: d.detected_bg.map(hex), + bg_pixels_removed: d.bg_pixels_removed, + dither_pairs: d + .dither_pairs + .iter() + .map(|&(a, b, n)| DitherPairReport { + color_a: hex(a), + color_b: hex(b), + alternating_blocks: n, + }) + .collect(), + low_confidence_blocks: low, + total_blocks: total, + warnings, + duration_ms, + } + } +} + +/// Result document for `sheet`. +#[derive(Debug, Serialize)] +pub struct SheetReport { + #[serde(flatten)] + pub process: ProcessReport, + pub sprites: u32, + pub tile_size: [u32; 2], + pub sprites_dir: Option, +} + +/// Result document for `batch`. +#[derive(Debug, Serialize)] +pub struct BatchReport { + pub summary: BatchSummaryReport, + pub files: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BatchSummaryReport { + pub total: usize, + pub succeeded: usize, + pub failed: usize, + pub skipped: usize, +} + +#[derive(Debug, Serialize)] +pub struct BatchFileReport { + pub input: String, + pub output: Option, + pub status: FileStatus, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FileStatus { + Ok, + Failed, + Skipped, +} + +/// Result document for `analyze` — inspection without writes. +#[derive(Debug, Serialize)] +pub struct AnalyzeReport { + pub input: String, + pub size: [u32; 2], + /// Frame count: 1 for still images, the animation's frame count for + /// animated GIF inputs (which are analyzed on their first frame). + pub frames: u32, + pub grid: Option, + pub grid_best_guess: Option, + pub logical_size: Option<[u32; 2]>, + pub unique_colors: u32, + pub detected_bg: Option, + pub bg_border_coverage: Option, + pub sheet_sprites: Option, + /// Contested-block rates when snapping at 1x/2x/4x of the detected + /// pitch. The detected pitch is the RENDER quantum; a low rate at a + /// multiple means the content also reads cleanly at that coarser + /// artistic resolution. + pub coarsen_candidates: Vec, + /// Largest factor whose contested rate stays within 15 percentage + /// points of the 1x baseline (and below 50% absolute). Null when only + /// 1x qualifies — the render quantum IS the art. The choice between + /// qualifying factors is taste; the rates are all published above. + pub suggested_coarsen: Option, + pub warnings: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CoarsenCandidate { + pub factor: u32, + pub pitch_x: f32, + pub logical_size: [u32; 2], + pub contested_rate: f32, +} + +/// NDJSON per-file progress on stderr (json mode). +#[derive(Debug, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum ProgressEvent { + BatchStarted { + total: usize, + }, + FileDone { + index: usize, + total: usize, + input: String, + output: Option, + status: FileStatus, + error: Option, + }, +} + +/// "#RRGGBB" for report fields. +pub fn hex(rgb: [u8; 3]) -> String { + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) +} diff --git a/src/spritesheet.rs b/src/spritesheet/autosplit.rs similarity index 58% rename from src/spritesheet.rs rename to src/spritesheet/autosplit.rs index a0d3eb8..a5e4beb 100644 --- a/src/spritesheet.rs +++ b/src/spritesheet/autosplit.rs @@ -1,219 +1,13 @@ -//! Sprite sheet processing: split, normalize, and reassemble. +//! Auto-split: detect sprites in AI-generated sheets with uneven spacing. -use std::collections::VecDeque; - -use anyhow::Result; -use image::{GenericImageView, Rgba, RgbaImage}; -use rayon::prelude::*; +use image::{Rgba, RgbaImage}; use tracing::info; +use super::Tile; use crate::color::oklab::{oklab_distance, rgba_to_oklab}; -use crate::pipeline::{ - detect_border_color, grid_detect, run_pipeline, PipelineConfig, PipelineState, -}; - -/// A tile extracted from a sprite sheet. -pub struct Tile { - /// Column index in the grid. - pub col: u32, - /// Row index in the grid. - pub row: u32, - /// The tile image. - pub image: RgbaImage, -} - -/// Split a sprite sheet into individual tiles on a regular grid. -/// -/// - `tile_w`, `tile_h`: dimensions of each tile -/// - `spacing`: gap between tiles (in pixels) -/// - `margin`: border around the entire sheet (in pixels) -pub fn split_sheet( - image: &RgbaImage, - tile_w: u32, - tile_h: u32, - spacing: u32, - margin: u32, -) -> Vec { - let (img_w, img_h) = image.dimensions(); - let mut tiles = Vec::new(); - - let mut y = margin; - let mut row = 0; - while y + tile_h <= img_h { - let mut x = margin; - let mut col = 0; - while x + tile_w <= img_w { - let tile_img = image.view(x, y, tile_w, tile_h).to_image(); - tiles.push(Tile { - col, - row, - image: tile_img, - }); - x += tile_w + spacing; - col += 1; - } - y += tile_h + spacing; - row += 1; - } - - info!( - tiles = tiles.len(), - cols = if tiles.is_empty() { 0 } else { tiles.last().unwrap().col + 1 }, - rows = row, - tile_w, - tile_h, - "Split sprite sheet into tiles" - ); - - tiles -} - -/// Reassemble tiles into a sprite sheet. -/// -/// Tiles are placed on a regular grid based on their (col, row) indices. -/// The output background is fully transparent. -pub fn assemble_sheet( - tiles: &[Tile], - tile_w: u32, - tile_h: u32, - spacing: u32, - margin: u32, -) -> RgbaImage { - if tiles.is_empty() { - return RgbaImage::new(0, 0); - } - - let max_col = tiles.iter().map(|t| t.col).max().unwrap_or(0); - let max_row = tiles.iter().map(|t| t.row).max().unwrap_or(0); - - let cols = max_col + 1; - let rows = max_row + 1; - - let out_w = margin * 2 + cols * tile_w + (cols.saturating_sub(1)) * spacing; - let out_h = margin * 2 + rows * tile_h + (rows.saturating_sub(1)) * spacing; - - let mut output = RgbaImage::from_pixel(out_w, out_h, Rgba([0, 0, 0, 0])); - - for tile in tiles { - let x = margin + tile.col * (tile_w + spacing); - let y = margin + tile.row * (tile_h + spacing); - - // Copy tile pixels into the output - for ty in 0..tile.image.height().min(tile_h) { - for tx in 0..tile.image.width().min(tile_w) { - let px = x + tx; - let py = y + ty; - if px < out_w && py < out_h { - output.put_pixel(px, py, *tile.image.get_pixel(tx, ty)); - } - } - } - } - - info!( - width = out_w, - height = out_h, - cols, - rows, - "Assembled sprite sheet" - ); - - output -} - -/// Build a tile-safe pipeline config from the caller's config. -/// -/// - Detects grid once on the full sheet image for consistency across all tiles. -/// - Clears output dimensions (those apply to the sheet, not individual tiles). -fn make_tile_config(image: &RgbaImage, config: &PipelineConfig) -> PipelineConfig { - let mut tc = config.clone(); - - // Detect grid once on the full sheet so every tile uses the same grid size/phase. - if !tc.grid.skip && tc.grid.override_size.is_none() { - let mut temp = PipelineState::new(image.clone()); - if grid_detect::detect_grid(&mut temp, &tc.grid).is_ok() { - if let Some(gs) = temp.grid_size { - tc.grid.override_size = Some(gs); - } - if let Some(phase) = temp.grid_phase { - tc.grid.override_phase = Some(phase); - } - } - } - - // Output dimensions are sheet-level; individual tiles should stay at their - // post-pipeline resolution so reassembly works correctly. - tc.output_width = None; - tc.output_height = None; - - tc -} - -/// Split a sprite sheet → normalize each tile → reassemble. -/// -/// Grid detection runs once on the full sheet for consistency, then each tile -/// is processed through the pipeline in parallel with the same grid settings. -/// -/// Returns `(assembled_sheet, processed_tiles, actual_tile_w, actual_tile_h)`. -pub fn process_sheet( - image: &RgbaImage, - tile_w: u32, - tile_h: u32, - spacing: u32, - margin: u32, - config: &PipelineConfig, -) -> Result<(RgbaImage, Vec, u32, u32)> { - let tiles = split_sheet(image, tile_w, tile_h, spacing, margin); - - if tiles.is_empty() { - anyhow::bail!( - "No tiles found in {}x{} image with tile size {}x{}, spacing {}, margin {}", - image.width(), - image.height(), - tile_w, - tile_h, - spacing, - margin - ); - } - - let tile_config = make_tile_config(image, config); - - eprintln!( - "Processing {} tiles ({}x{} each, grid={:?})...", - tiles.len(), - tile_w, - tile_h, - tile_config.grid.override_size, - ); - - // Process each tile through the pipeline in parallel - let processed: Result> = tiles - .into_par_iter() - .map(|tile| { - let state = run_pipeline(tile.image, &tile_config)?; - Ok(Tile { - col: tile.col, - row: tile.row, - image: state.image, - }) - }) - .collect(); - - let processed = processed?; - - // Use actual processed tile dimensions for reassembly (may differ from - // input tile_w/tile_h if the pipeline downscaled). - let actual_tw = processed.first().map(|t| t.image.width()).unwrap_or(tile_w); - let actual_th = processed.first().map(|t| t.image.height()).unwrap_or(tile_h); - - let sheet = assemble_sheet(&processed, actual_tw, actual_th, spacing, margin); - Ok((sheet, processed, actual_tw, actual_th)) -} - -// --------------------------------------------------------------------------- -// Auto-split: detect sprites in AI-generated sheets with uneven spacing -// --------------------------------------------------------------------------- +use crate::error::{NormalizeError, Result}; +use crate::image_util::flood::flood_fill_from_border; +use crate::pipeline::detect_border_color; /// Configuration for auto-split mode. #[derive(Debug, Clone)] @@ -278,10 +72,7 @@ fn is_autosplit_bg(pixel: Rgba, bg: &AutoSplitBg, tolerance: f32) -> bool { } /// Detect whether the sheet uses transparency or a solid background color. -pub fn detect_autosplit_background( - image: &RgbaImage, - config: &AutoSplitConfig, -) -> AutoSplitBg { +pub fn detect_autosplit_background(image: &RgbaImage, config: &AutoSplitConfig) -> AutoSplitBg { // If explicit color, use it if let Some(rgb) = config.bg_color { let rgba = Rgba([rgb[0], rgb[1], rgb[2], 255]); @@ -487,8 +278,6 @@ fn extract_cells( max_rel_right: i32, /// Maximum height across all frames max_h: u32, - /// Maximum distance from cell bottom to sprite bottom - max_bottom_gap: i32, } let mut row_envelopes: HashMap = HashMap::new(); @@ -497,20 +286,15 @@ fn extract_cells( let cell_cx = c.cell.x as i32 + c.cell.w as i32 / 2; let rel_left = c.bbox.x as i32 - cell_cx; let rel_right = (c.bbox.x + c.bbox.w) as i32 - cell_cx; - let cell_bottom = (c.cell.y + c.cell.h) as i32; - let bbox_bottom = (c.bbox.y + c.bbox.h) as i32; - let bottom_gap = cell_bottom - bbox_bottom; let env = row_envelopes.entry(c.row).or_insert(RowEnvelope { min_rel_left: rel_left, max_rel_right: rel_right, max_h: c.bbox.h, - max_bottom_gap: bottom_gap, }); env.min_rel_left = env.min_rel_left.min(rel_left); env.max_rel_right = env.max_rel_right.max(rel_right); env.max_h = env.max_h.max(c.bbox.h); - env.max_bottom_gap = env.max_bottom_gap.min(bottom_gap); } // Global tile size: max envelope across all rows @@ -519,11 +303,7 @@ fn extract_cells( .map(|e| (e.max_rel_right - e.min_rel_left) as u32) .max() .unwrap(); - let env_max_h = row_envelopes - .values() - .map(|e| e.max_h) - .max() - .unwrap(); + let env_max_h = row_envelopes.values().map(|e| e.max_h).max().unwrap(); let tile_w = env_max_w + pad * 2; let tile_h = env_max_h + pad * 2; @@ -555,41 +335,13 @@ fn extract_cells( // Flood-fill from the edges of the tile to remove only background // pixels that are connected to the border. This preserves white // pixels inside the sprite (e.g., eyes, highlights). - let tw = tile_w as usize; - let th = tile_h as usize; - let mut visited = vec![false; tw * th]; - let mut queue = VecDeque::new(); - - // Seed from all border pixels of the tile - for x in 0..tile_w { - for &y in &[0, tile_h - 1] { - let idx = y as usize * tw + x as usize; - if !visited[idx] && is_autosplit_bg(*tile_img.get_pixel(x, y), bg, tolerance) { - visited[idx] = true; - queue.push_back((x, y)); - } - } - } - for y in 1..tile_h.saturating_sub(1) { - for &x in &[0, tile_w - 1] { - let idx = y as usize * tw + x as usize; - if !visited[idx] && is_autosplit_bg(*tile_img.get_pixel(x, y), bg, tolerance) { - visited[idx] = true; - queue.push_back((x, y)); - } - } - } - - // BFS: spread to adjacent background pixels - while let Some((x, y)) = queue.pop_front() { - tile_img.put_pixel(x, y, Rgba([0, 0, 0, 0])); - for (nx, ny) in [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)] { - if nx < tile_w && ny < tile_h { - let idx = ny as usize * tw + nx as usize; - if !visited[idx] && is_autosplit_bg(*tile_img.get_pixel(nx, ny), bg, tolerance) { - visited[idx] = true; - queue.push_back((nx, ny)); - } + let mask = flood_fill_from_border(tile_w, tile_h, |x, y| { + is_autosplit_bg(*tile_img.get_pixel(x, y), bg, tolerance) + }); + for y in 0..tile_h { + for x in 0..tile_w { + if mask[(y * tile_w + x) as usize] { + tile_img.put_pixel(x, y, Rgba([0, 0, 0, 0])); } } } @@ -597,6 +349,8 @@ fn extract_cells( Tile { col: c.col, row: c.row, + origin_x: c.bbox.x as i32 - ox as i32, + origin_y: c.bbox.y as i32 - oy as i32, image: tile_img, } }) @@ -615,8 +369,10 @@ pub fn auto_split_sheet( let bg = detect_autosplit_background(image, config); info!(?bg, "Auto-split background detection"); - let sep_rows = classify_separator_rows(image, &bg, config.tolerance, config.separator_threshold); - let sep_cols = classify_separator_cols(image, &bg, config.tolerance, config.separator_threshold); + let sep_rows = + classify_separator_rows(image, &bg, config.tolerance, config.separator_threshold); + let sep_cols = + classify_separator_cols(image, &bg, config.tolerance, config.separator_threshold); let row_bands = extract_bands(&sep_rows, config.min_sprite_size); let col_bands = extract_bands(&sep_cols, config.min_sprite_size); @@ -628,14 +384,14 @@ pub fn auto_split_sheet( ); if row_bands.is_empty() || col_bands.is_empty() { - anyhow::bail!( - "No sprite regions detected in {}x{} image (found {} row bands, {} col bands). \ + return Err(NormalizeError::InvalidInput(format!( + "no sprite regions detected in {}x{} image (found {} row bands, {} col bands). \ Try adjusting --separator-threshold or --min-sprite-size.", image.width(), image.height(), row_bands.len(), col_bands.len(), - ); + ))); } let (tiles, tile_w, tile_h) = extract_cells( @@ -649,173 +405,23 @@ pub fn auto_split_sheet( ); if tiles.is_empty() { - anyhow::bail!( - "All detected cells were empty or below minimum size ({}px). \ + return Err(NormalizeError::InvalidInput(format!( + "all detected cells were empty or below minimum size ({}px). \ Try reducing --min-sprite-size.", config.min_sprite_size - ); + ))); } - info!( - sprites = tiles.len(), - tile_w, - tile_h, - "Auto-split complete" - ); + info!(sprites = tiles.len(), tile_w, tile_h, "Auto-split complete"); Ok((tiles, tile_w, tile_h)) } -/// Auto-split a sheet, optionally normalize each tile, and reassemble. -/// -/// Grid detection runs once on the full sheet for consistency when a pipeline -/// config is provided. -pub fn process_sheet_auto( - image: &RgbaImage, - auto_config: &AutoSplitConfig, - pipeline_config: Option<&PipelineConfig>, -) -> Result<(RgbaImage, Vec, u32, u32)> { - let (tiles, tile_w, tile_h) = auto_split_sheet(image, auto_config)?; - - eprintln!( - "Auto-detected {} sprites (uniform tile size: {}x{})", - tiles.len(), - tile_w, - tile_h - ); - - let (processed, out_tw, out_th) = if let Some(config) = pipeline_config { - let tile_config = make_tile_config(image, config); - let result: Result> = tiles - .into_par_iter() - .map(|tile| { - let state = run_pipeline(tile.image, &tile_config)?; - Ok(Tile { - col: tile.col, - row: tile.row, - image: state.image, - }) - }) - .collect(); - let processed = result?; - let actual_tw = processed.first().map(|t| t.image.width()).unwrap_or(tile_w); - let actual_th = processed.first().map(|t| t.image.height()).unwrap_or(tile_h); - (processed, actual_tw, actual_th) - } else { - (tiles, tile_w, tile_h) - }; - - let sheet = assemble_sheet(&processed, out_tw, out_th, 0, 0); - Ok((sheet, processed, out_tw, out_th)) -} - #[cfg(test)] mod tests { use super::*; use image::Rgba; - fn make_test_sheet() -> RgbaImage { - // 2x2 grid of 4x4 tiles, no spacing, no margin = 8x8 image - let red = Rgba([255, 0, 0, 255]); - let green = Rgba([0, 255, 0, 255]); - let blue = Rgba([0, 0, 255, 255]); - let yellow = Rgba([255, 255, 0, 255]); - - let mut img = RgbaImage::new(8, 8); - for y in 0..4 { - for x in 0..4 { - img.put_pixel(x, y, red); - } - for x in 4..8 { - img.put_pixel(x, y, green); - } - } - for y in 4..8 { - for x in 0..4 { - img.put_pixel(x, y, blue); - } - for x in 4..8 { - img.put_pixel(x, y, yellow); - } - } - img - } - - #[test] - fn test_split_sheet_basic() { - let sheet = make_test_sheet(); - let tiles = split_sheet(&sheet, 4, 4, 0, 0); - assert_eq!(tiles.len(), 4); - assert_eq!(tiles[0].col, 0); - assert_eq!(tiles[0].row, 0); - assert_eq!(tiles[1].col, 1); - assert_eq!(tiles[1].row, 0); - assert_eq!(tiles[2].col, 0); - assert_eq!(tiles[2].row, 1); - assert_eq!(tiles[3].col, 1); - assert_eq!(tiles[3].row, 1); - - // Check tile colors - assert_eq!(*tiles[0].image.get_pixel(0, 0), Rgba([255, 0, 0, 255])); - assert_eq!(*tiles[1].image.get_pixel(0, 0), Rgba([0, 255, 0, 255])); - assert_eq!(*tiles[2].image.get_pixel(0, 0), Rgba([0, 0, 255, 255])); - assert_eq!(*tiles[3].image.get_pixel(0, 0), Rgba([255, 255, 0, 255])); - } - - #[test] - fn test_split_and_reassemble_roundtrip() { - let sheet = make_test_sheet(); - let tiles = split_sheet(&sheet, 4, 4, 0, 0); - let reassembled = assemble_sheet(&tiles, 4, 4, 0, 0); - - assert_eq!(reassembled.dimensions(), sheet.dimensions()); - for y in 0..8 { - for x in 0..8 { - assert_eq!( - reassembled.get_pixel(x, y), - sheet.get_pixel(x, y), - "Pixel mismatch at ({}, {})", - x, - y - ); - } - } - } - - #[test] - fn test_split_with_spacing() { - // 2x2 grid of 4x4 tiles with 2px spacing = 10x10 image - let mut img = RgbaImage::from_pixel(10, 10, Rgba([128, 128, 128, 255])); - let red = Rgba([255, 0, 0, 255]); - for y in 0..4 { - for x in 0..4 { - img.put_pixel(x, y, red); - } - } - - let tiles = split_sheet(&img, 4, 4, 2, 0); - assert_eq!(tiles.len(), 4); - assert_eq!(*tiles[0].image.get_pixel(0, 0), red); - } - - #[test] - fn test_split_with_margin() { - // 1 tile of 4x4 with 2px margin = 8x8 image - let mut img = RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 255])); - let red = Rgba([255, 0, 0, 255]); - for y in 2..6 { - for x in 2..6 { - img.put_pixel(x, y, red); - } - } - - let tiles = split_sheet(&img, 4, 4, 0, 2); - assert_eq!(tiles.len(), 1); - assert_eq!(*tiles[0].image.get_pixel(0, 0), red); - } - - // --- Auto-split tests --- - #[test] fn test_classify_separator_rows() { let white = Rgba([255, 255, 255, 255]); @@ -878,7 +484,15 @@ mod tests { h: 10, }; let bbox = tight_bbox(&img, region, &bg, 0.05).unwrap(); - assert_eq!(bbox, BBox { x: 3, y: 4, w: 3, h: 2 }); + assert_eq!( + bbox, + BBox { + x: 3, + y: 4, + w: 3, + h: 2 + } + ); } #[test] diff --git a/src/spritesheet/fixed.rs b/src/spritesheet/fixed.rs new file mode 100644 index 0000000..5400083 --- /dev/null +++ b/src/spritesheet/fixed.rs @@ -0,0 +1,217 @@ +//! Fixed-grid sprite sheet split and reassembly. + +use image::{GenericImageView, Rgba, RgbaImage}; +use tracing::info; + +use super::Tile; + +/// Split a sprite sheet into individual tiles on a regular grid. +/// +/// - `tile_w`, `tile_h`: dimensions of each tile +/// - `spacing`: gap between tiles (in pixels) +/// - `margin`: border around the entire sheet (in pixels) +pub fn split_sheet( + image: &RgbaImage, + tile_w: u32, + tile_h: u32, + spacing: u32, + margin: u32, +) -> Vec { + let (img_w, img_h) = image.dimensions(); + let mut tiles = Vec::new(); + + let mut y = margin; + let mut row = 0; + while y + tile_h <= img_h { + let mut x = margin; + let mut col = 0; + while x + tile_w <= img_w { + let tile_img = image.view(x, y, tile_w, tile_h).to_image(); + tiles.push(Tile { + col, + row, + origin_x: x as i32, + origin_y: y as i32, + image: tile_img, + }); + x += tile_w + spacing; + col += 1; + } + y += tile_h + spacing; + row += 1; + } + + info!( + tiles = tiles.len(), + cols = if tiles.is_empty() { + 0 + } else { + tiles.last().unwrap().col + 1 + }, + rows = row, + tile_w, + tile_h, + "Split sprite sheet into tiles" + ); + + tiles +} + +/// Reassemble tiles into a sprite sheet. +/// +/// Tiles are placed on a regular grid based on their (col, row) indices. +/// The output background is fully transparent. +pub fn assemble_sheet( + tiles: &[Tile], + tile_w: u32, + tile_h: u32, + spacing: u32, + margin: u32, +) -> RgbaImage { + if tiles.is_empty() { + return RgbaImage::new(0, 0); + } + + let max_col = tiles.iter().map(|t| t.col).max().unwrap_or(0); + let max_row = tiles.iter().map(|t| t.row).max().unwrap_or(0); + + let cols = max_col + 1; + let rows = max_row + 1; + + let out_w = margin * 2 + cols * tile_w + (cols.saturating_sub(1)) * spacing; + let out_h = margin * 2 + rows * tile_h + (rows.saturating_sub(1)) * spacing; + + let mut output = RgbaImage::from_pixel(out_w, out_h, Rgba([0, 0, 0, 0])); + + for tile in tiles { + let x = margin + tile.col * (tile_w + spacing); + let y = margin + tile.row * (tile_h + spacing); + + // Copy tile pixels into the output + for ty in 0..tile.image.height().min(tile_h) { + for tx in 0..tile.image.width().min(tile_w) { + let px = x + tx; + let py = y + ty; + if px < out_w && py < out_h { + output.put_pixel(px, py, *tile.image.get_pixel(tx, ty)); + } + } + } + } + + info!( + width = out_w, + height = out_h, + cols, + rows, + "Assembled sprite sheet" + ); + + output +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + fn make_test_sheet() -> RgbaImage { + // 2x2 grid of 4x4 tiles, no spacing, no margin = 8x8 image + let red = Rgba([255, 0, 0, 255]); + let green = Rgba([0, 255, 0, 255]); + let blue = Rgba([0, 0, 255, 255]); + let yellow = Rgba([255, 255, 0, 255]); + + let mut img = RgbaImage::new(8, 8); + for y in 0..4 { + for x in 0..4 { + img.put_pixel(x, y, red); + } + for x in 4..8 { + img.put_pixel(x, y, green); + } + } + for y in 4..8 { + for x in 0..4 { + img.put_pixel(x, y, blue); + } + for x in 4..8 { + img.put_pixel(x, y, yellow); + } + } + img + } + + #[test] + fn test_split_sheet_basic() { + let sheet = make_test_sheet(); + let tiles = split_sheet(&sheet, 4, 4, 0, 0); + assert_eq!(tiles.len(), 4); + assert_eq!(tiles[0].col, 0); + assert_eq!(tiles[0].row, 0); + assert_eq!(tiles[1].col, 1); + assert_eq!(tiles[1].row, 0); + assert_eq!(tiles[2].col, 0); + assert_eq!(tiles[2].row, 1); + assert_eq!(tiles[3].col, 1); + assert_eq!(tiles[3].row, 1); + + // Check tile colors + assert_eq!(*tiles[0].image.get_pixel(0, 0), Rgba([255, 0, 0, 255])); + assert_eq!(*tiles[1].image.get_pixel(0, 0), Rgba([0, 255, 0, 255])); + assert_eq!(*tiles[2].image.get_pixel(0, 0), Rgba([0, 0, 255, 255])); + assert_eq!(*tiles[3].image.get_pixel(0, 0), Rgba([255, 255, 0, 255])); + } + + #[test] + fn test_split_and_reassemble_roundtrip() { + let sheet = make_test_sheet(); + let tiles = split_sheet(&sheet, 4, 4, 0, 0); + let reassembled = assemble_sheet(&tiles, 4, 4, 0, 0); + + assert_eq!(reassembled.dimensions(), sheet.dimensions()); + for y in 0..8 { + for x in 0..8 { + assert_eq!( + reassembled.get_pixel(x, y), + sheet.get_pixel(x, y), + "Pixel mismatch at ({}, {})", + x, + y + ); + } + } + } + + #[test] + fn test_split_with_spacing() { + // 2x2 grid of 4x4 tiles with 2px spacing = 10x10 image + let mut img = RgbaImage::from_pixel(10, 10, Rgba([128, 128, 128, 255])); + let red = Rgba([255, 0, 0, 255]); + for y in 0..4 { + for x in 0..4 { + img.put_pixel(x, y, red); + } + } + + let tiles = split_sheet(&img, 4, 4, 2, 0); + assert_eq!(tiles.len(), 4); + assert_eq!(*tiles[0].image.get_pixel(0, 0), red); + } + + #[test] + fn test_split_with_margin() { + // 1 tile of 4x4 with 2px margin = 8x8 image + let mut img = RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 255])); + let red = Rgba([255, 0, 0, 255]); + for y in 2..6 { + for x in 2..6 { + img.put_pixel(x, y, red); + } + } + + let tiles = split_sheet(&img, 4, 4, 0, 2); + assert_eq!(tiles.len(), 1); + assert_eq!(*tiles[0].image.get_pixel(0, 0), red); + } +} diff --git a/src/spritesheet/mod.rs b/src/spritesheet/mod.rs new file mode 100644 index 0000000..5a49be1 --- /dev/null +++ b/src/spritesheet/mod.rs @@ -0,0 +1,198 @@ +//! Sprite sheet processing: split, normalize, and reassemble. +//! +//! Two unrelated systems live here as submodules: +//! - [`fixed`]: fixed-grid split/assemble for well-formed sheets with known +//! tile size, spacing, and margin. +//! - [`autosplit`]: sprite detection for messy AI-generated sheets with +//! uneven spacing. + +mod autosplit; +mod fixed; + +pub use autosplit::*; +pub use fixed::*; + +use image::RgbaImage; +use tracing::info; + +use crate::error::{NormalizeError, Result}; +use crate::parallel::*; +use crate::pipeline::{grid_detect, run_pipeline, Grid, PipelineConfig, PipelineState}; + +/// A tile extracted from a sprite sheet. +pub struct Tile { + /// Column index in the grid. + pub col: u32, + /// Row index in the grid. + pub row: u32, + /// Sheet coordinates of this tile's top-left pixel — needed to rebase + /// the sheet-level grid phase into tile-local coordinates. + pub origin_x: i32, + pub origin_y: i32, + /// The tile image. + pub image: RgbaImage, +} + +/// Build a tile-safe pipeline config plus the sheet-level grid. +/// +/// - Detects the grid once on the full sheet for consistency across tiles; +/// each tile then gets the grid REBASED to its own origin, so margins and +/// spacing that aren't a multiple of the pitch no longer shift every +/// tile's phase. +/// - Clears output dimensions (those apply to the sheet, not tiles). +fn make_tile_config(image: &RgbaImage, config: &PipelineConfig) -> (PipelineConfig, Option) { + let mut tc = config.clone(); + + let sheet_grid = if tc.grid.override_grid.is_some() { + tc.grid.override_grid + } else if !tc.grid.skip || tc.grid.override_size.is_some() { + let mut temp = PipelineState::new(image.clone()); + match grid_detect::detect_grid(&mut temp, &tc.grid) { + Ok(()) => temp.grid, + Err(_) => None, + } + } else { + None + }; + + // Tiles must not re-detect; they get an explicit per-tile override (or + // no grid processing at all when sheet detection declined). + tc.grid.skip = true; + tc.grid.override_size = None; + tc.grid.override_phase = None; + tc.grid.override_grid = None; + + // Output dimensions are sheet-level; individual tiles should stay at their + // post-pipeline resolution so reassembly works correctly. + tc.output_width = None; + tc.output_height = None; + + (tc, sheet_grid) +} + +/// Per-tile pipeline config: the sheet grid rebased into tile coordinates. +fn config_for_tile(base: &PipelineConfig, sheet_grid: Option, tile: &Tile) -> PipelineConfig { + let mut tc = base.clone(); + tc.grid.override_grid = sheet_grid.map(|g| g.rebase(tile.origin_x, tile.origin_y)); + tc +} + +/// Split a sprite sheet → normalize each tile → reassemble. +/// +/// Grid detection runs once on the full sheet for consistency, then each tile +/// is processed through the pipeline in parallel with the same grid settings. +/// +/// Returns `(assembled_sheet, processed_tiles, actual_tile_w, actual_tile_h)`. +pub fn process_sheet( + image: &RgbaImage, + tile_w: u32, + tile_h: u32, + spacing: u32, + margin: u32, + config: &PipelineConfig, +) -> Result<(RgbaImage, Vec, u32, u32)> { + let tiles = split_sheet(image, tile_w, tile_h, spacing, margin); + + if tiles.is_empty() { + return Err(NormalizeError::InvalidInput(format!( + "no tiles found in {}x{} image with tile size {}x{}, spacing {}, margin {}", + image.width(), + image.height(), + tile_w, + tile_h, + spacing, + margin + ))); + } + + let (tile_config, sheet_grid) = make_tile_config(image, config); + + info!( + tiles = tiles.len(), + tile_w, + tile_h, + grid = ?sheet_grid, + "Processing tiles" + ); + + // Process each tile through the pipeline in parallel + let processed: Result> = tiles + .into_par_iter() + .map(|tile| { + let tc = config_for_tile(&tile_config, sheet_grid, &tile); + let state = run_pipeline(tile.image, &tc)?; + Ok(Tile { + col: tile.col, + row: tile.row, + origin_x: tile.origin_x, + origin_y: tile.origin_y, + image: state.image, + }) + }) + .collect(); + + let processed = processed?; + + // Use the max processed tile dimensions for reassembly (rebased phases + // can shift block counts by one between tiles). + let actual_tw = processed + .iter() + .map(|t| t.image.width()) + .max() + .unwrap_or(tile_w); + let actual_th = processed + .iter() + .map(|t| t.image.height()) + .max() + .unwrap_or(tile_h); + + let sheet = assemble_sheet(&processed, actual_tw, actual_th, spacing, margin); + Ok((sheet, processed, actual_tw, actual_th)) +} + +/// Auto-split a sheet, optionally normalize each tile, and reassemble. +/// +/// Grid detection runs once on the full sheet for consistency when a pipeline +/// config is provided. +pub fn process_sheet_auto( + image: &RgbaImage, + auto_config: &AutoSplitConfig, + pipeline_config: Option<&PipelineConfig>, +) -> Result<(RgbaImage, Vec, u32, u32)> { + let (tiles, tile_w, tile_h) = auto_split_sheet(image, auto_config)?; + + info!( + sprites = tiles.len(), + tile_w, tile_h, "Auto-detected sprites (uniform tile size)" + ); + + let (processed, out_tw, out_th) = if let Some(config) = pipeline_config { + let (tile_config, sheet_grid) = make_tile_config(image, config); + let result: Result> = tiles + .into_par_iter() + .map(|tile| { + let tc = config_for_tile(&tile_config, sheet_grid, &tile); + let state = run_pipeline(tile.image, &tc)?; + Ok(Tile { + col: tile.col, + row: tile.row, + origin_x: tile.origin_x, + origin_y: tile.origin_y, + image: state.image, + }) + }) + .collect(); + let processed = result?; + let actual_tw = processed.first().map(|t| t.image.width()).unwrap_or(tile_w); + let actual_th = processed + .first() + .map(|t| t.image.height()) + .unwrap_or(tile_h); + (processed, actual_tw, actual_th) + } else { + (tiles, tile_w, tile_h) + }; + + let sheet = assemble_sheet(&processed, out_tw, out_th, 0, 0); + Ok((sheet, processed, out_tw, out_th)) +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 0e4e4a2..7a9c731 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -6,7 +6,7 @@ use ratatui_image::protocol::StatefulProtocol; use crate::image_util::histogram::ColorHistogram; use crate::image_util::io::{load_image, save_image}; -use crate::pipeline::{PipelineConfig, PipelineDiagnostics, run_pipeline}; +use crate::pipeline::{run_pipeline, PipelineConfig, PipelineDiagnostics}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -189,18 +189,23 @@ impl App { // Build status summary let grid_info = if let Some(ref diag) = self.diagnostics { if let Some(conf) = diag.grid_confidence { - format!("Grid: {} ({:.0}%)", - self.config.grid.override_size + format!( + "Grid: {} ({:.0}%)", + self.config + .grid + .override_size .map(|s| s.to_string()) .unwrap_or_else(|| "auto".to_string()), - conf * 100.0) + conf * 100.0 + ) } else { "Grid: none".to_string() } } else { String::new() }; - self.status_message = format!("Done. {} | {} unique colors", grid_info, self.unique_colors); + self.status_message = + format!("Done. {} | {} unique colors", grid_info, self.unique_colors); self.status_level = StatusLevel::Success; } Err(e) => { @@ -251,11 +256,25 @@ impl App { let id = self.settings[self.settings_selected]; match id { SettingId::GridSize => { - const SIZES: &[Option] = &[ - None, Some(2), Some(3), Some(4), Some(5), Some(6), - Some(7), Some(8), Some(10), Some(12), Some(16), Some(24), Some(32), + const SIZES: &[Option] = &[ + None, + Some(2.0), + Some(3.0), + Some(4.0), + Some(5.0), + Some(6.0), + Some(7.0), + Some(8.0), + Some(10.0), + Some(12.0), + Some(16.0), + Some(24.0), + Some(32.0), ]; - let current = SIZES.iter().position(|s| *s == self.config.grid.override_size).unwrap_or(0); + let current = SIZES + .iter() + .position(|s| *s == self.config.grid.override_size) + .unwrap_or(0); let next = (current as i32 + delta).rem_euclid(SIZES.len() as i32) as usize; self.config.grid.override_size = SIZES[next]; self.config.grid.skip = false; @@ -268,25 +287,49 @@ impl App { DownscaleMode::MajorityVote, DownscaleMode::CenterPixel, ]; - let current = MODES.iter().position(|m| *m == self.config.downscale_mode).unwrap_or(0); + let current = MODES + .iter() + .position(|m| *m == self.config.downscale.mode) + .unwrap_or(0); let next = (current as i32 + delta).rem_euclid(MODES.len() as i32) as usize; - self.config.downscale_mode = MODES[next]; + self.config.downscale.mode = MODES[next]; } SettingId::AaThreshold => { const VALS: &[Option] = &[ - None, Some(0.1), Some(0.2), Some(0.3), Some(0.4), Some(0.5), - Some(0.6), Some(0.7), Some(0.8), Some(0.9), Some(1.0), + None, + Some(0.1), + Some(0.2), + Some(0.3), + Some(0.4), + Some(0.5), + Some(0.6), + Some(0.7), + Some(0.8), + Some(0.9), + Some(1.0), ]; - let current_val = if self.config.aa.skip { None } else { Some(self.config.aa.threshold) }; - let current = VALS.iter().position(|v| match (v, ¤t_val) { - (None, None) => true, - (Some(a), Some(b)) => (*a - *b).abs() < 0.01, - _ => false, - }).unwrap_or(0); + let current_val = if self.config.aa.skip { + None + } else { + Some(self.config.aa.threshold) + }; + let current = VALS + .iter() + .position(|v| match (v, ¤t_val) { + (None, None) => true, + (Some(a), Some(b)) => (*a - *b).abs() < 0.01, + _ => false, + }) + .unwrap_or(0); let next = (current as i32 + delta).rem_euclid(VALS.len() as i32) as usize; match VALS[next] { - None => { self.config.aa.skip = true; } - Some(t) => { self.config.aa.skip = false; self.config.aa.threshold = t; } + None => { + self.config.aa.skip = true; + } + Some(t) => { + self.config.aa.skip = false; + self.config.aa.threshold = t; + } } } SettingId::PaletteName => { @@ -294,7 +337,10 @@ impl App { let names: Vec> = std::iter::once(None) .chain(ALL_PALETTES.iter().map(|p| Some(p.slug))) .collect(); - let current = names.iter().position(|n| *n == self.config.quantize.palette_name.as_deref()).unwrap_or(0); + let current = names + .iter() + .position(|n| *n == self.config.quantize.palette_name.as_deref()) + .unwrap_or(0); let next = (current as i32 + delta).rem_euclid(names.len() as i32) as usize; self.config.quantize.palette_name = names[next].map(String::from); if self.config.quantize.palette_name.is_some() { @@ -304,9 +350,19 @@ impl App { } SettingId::AutoColors => { const VALS: &[Option] = &[ - None, Some(4), Some(8), Some(16), Some(32), Some(64), Some(128), Some(256), + None, + Some(4), + Some(8), + Some(16), + Some(32), + Some(64), + Some(128), + Some(256), ]; - let current = VALS.iter().position(|v| *v == self.config.quantize.num_colors).unwrap_or(0); + let current = VALS + .iter() + .position(|v| *v == self.config.quantize.num_colors) + .unwrap_or(0); let next = (current as i32 + delta).rem_euclid(VALS.len() as i32) as usize; self.config.quantize.num_colors = VALS[next]; if self.config.quantize.num_colors.is_some() { @@ -319,7 +375,10 @@ impl App { } SettingId::BgTolerance => { const VALS: &[f32] = &[0.01, 0.02, 0.03, 0.05, 0.08, 0.10, 0.15, 0.20]; - let current = VALS.iter().position(|v| (*v - self.config.background.color_tolerance).abs() < 0.005).unwrap_or(3); + let current = VALS + .iter() + .position(|v| (*v - self.config.background.color_tolerance).abs() < 0.005) + .unwrap_or(3); let next = (current as i32 + delta).rem_euclid(VALS.len() as i32) as usize; self.config.background.color_tolerance = VALS[next]; } @@ -331,31 +390,46 @@ impl App { pub fn setting_display(&self, id: SettingId) -> String { match id { - SettingId::GridSize => { - self.config.grid.override_size - .map(|s| s.to_string()) - .unwrap_or_else(|| "auto".to_string()) - } - SettingId::DownscaleMode => format!("{}", self.config.downscale_mode), + SettingId::GridSize => self + .config + .grid + .override_size + .map(|s| s.to_string()) + .unwrap_or_else(|| "auto".to_string()), + SettingId::DownscaleMode => format!("{}", self.config.downscale.mode), SettingId::AaThreshold => { - if self.config.aa.skip { "off".to_string() } - else { format!("{:.1}", self.config.aa.threshold) } - } - SettingId::PaletteName => { - self.config.quantize.palette_name.as_deref().unwrap_or("none").to_string() - } - SettingId::AutoColors => { - self.config.quantize.num_colors - .map(|n| n.to_string()) - .unwrap_or_else(|| "off".to_string()) + if self.config.aa.skip { + "off".to_string() + } else { + format!("{:.1}", self.config.aa.threshold) + } } - SettingId::RemoveBg => { - if self.config.background.enabled { "on" } else { "off" }.to_string() + SettingId::PaletteName => self + .config + .quantize + .palette_name + .as_deref() + .unwrap_or("none") + .to_string(), + SettingId::AutoColors => self + .config + .quantize + .num_colors + .map(|n| n.to_string()) + .unwrap_or_else(|| "off".to_string()), + SettingId::RemoveBg => if self.config.background.enabled { + "on" + } else { + "off" } + .to_string(), SettingId::BgTolerance => format!("{:.2}", self.config.background.color_tolerance), - SettingId::FloodFill => { - if self.config.background.flood_fill { "on" } else { "off" }.to_string() + SettingId::FloodFill => if self.config.background.flood_fill { + "on" + } else { + "off" } + .to_string(), } } @@ -387,19 +461,34 @@ impl App { pub fn is_setting_changed(&self, id: SettingId) -> bool { match id { - SettingId::GridSize => self.config.grid.override_size != self.initial_config.grid.override_size, - SettingId::DownscaleMode => self.config.downscale_mode != self.initial_config.downscale_mode, + SettingId::GridSize => { + self.config.grid.override_size != self.initial_config.grid.override_size + } + SettingId::DownscaleMode => { + self.config.downscale.mode != self.initial_config.downscale.mode + } SettingId::AaThreshold => { self.config.aa.skip != self.initial_config.aa.skip || (self.config.aa.threshold - self.initial_config.aa.threshold).abs() > 0.001 } - SettingId::PaletteName => self.config.quantize.palette_name != self.initial_config.quantize.palette_name, - SettingId::AutoColors => self.config.quantize.num_colors != self.initial_config.quantize.num_colors, - SettingId::RemoveBg => self.config.background.enabled != self.initial_config.background.enabled, + SettingId::PaletteName => { + self.config.quantize.palette_name != self.initial_config.quantize.palette_name + } + SettingId::AutoColors => { + self.config.quantize.num_colors != self.initial_config.quantize.num_colors + } + SettingId::RemoveBg => { + self.config.background.enabled != self.initial_config.background.enabled + } SettingId::BgTolerance => { - (self.config.background.color_tolerance - self.initial_config.background.color_tolerance).abs() > 0.001 + (self.config.background.color_tolerance + - self.initial_config.background.color_tolerance) + .abs() + > 0.001 + } + SettingId::FloodFill => { + self.config.background.flood_fill != self.initial_config.background.flood_fill } - SettingId::FloodFill => self.config.background.flood_fill != self.initial_config.background.flood_fill, } } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 90570ef..61eec0f 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -3,8 +3,8 @@ use std::time::Duration; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::Terminal; use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; use super::app::{App, Tab, TextInputMode}; use super::ui; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a833c9f..7a6ba22 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -32,10 +32,7 @@ pub fn run_tui(initial_path: Option, config: PipelineConfig) -> Result< // 3. Enter alternate screen and enable raw mode crossterm::terminal::enable_raw_mode()?; let mut stdout = std::io::stdout(); - crossterm::execute!( - stdout, - crossterm::terminal::EnterAlternateScreen, - )?; + crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen,)?; let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = ratatui::Terminal::new(backend)?; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 8fa6507..3f615e7 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -48,7 +48,7 @@ pub fn draw(frame: &mut Frame, app: &mut App) { .direction(Direction::Vertical) .constraints([ Constraint::Length(4), // Header: title + tabs - Constraint::Min(6), // Content + Constraint::Min(6), // Content Constraint::Length(1), // Status Constraint::Length(1), // Keybindings ]) @@ -91,7 +91,7 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { // Title line let title = Line::from(vec![ Span::styled( - " normalize-pixelart", + " pixfix", Style::default() .fg(theme::LAVENDER) .add_modifier(Modifier::BOLD), @@ -144,7 +144,10 @@ fn draw_preview_tab(frame: &mut Frame, app: &mut App, area: Rect) { let left_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(theme::SURFACE2)) - .title(Span::styled(" Original ", Style::default().fg(theme::SUBTEXT1))); + .title(Span::styled( + " Original ", + Style::default().fg(theme::SUBTEXT1), + )); let left_inner = left_block.inner(columns[0]); frame.render_widget(left_block, columns[0]); @@ -185,10 +188,7 @@ fn draw_welcome_screen(frame: &mut Frame, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); - let mut lines = vec![ - Line::from(""), - Line::from(""), - ]; + let mut lines = vec![Line::from(""), Line::from("")]; // ASCII art logo for art_line in &LOGO_ART { @@ -201,7 +201,7 @@ fn draw_welcome_screen(frame: &mut Frame, area: Rect) { lines.extend([ Line::from(""), Line::from(Span::styled( - "normalize-pixelart", + "pixfix", Style::default() .fg(theme::LAVENDER) .add_modifier(Modifier::BOLD), @@ -225,7 +225,7 @@ fn draw_welcome_screen(frame: &mut Frame, area: Rect) { ]), Line::from(""), Line::from(Span::styled( - "or run: normalize-pixelart tui ", + "or run: pixfix tui ", Style::default().fg(theme::OVERLAY0), )), ]); @@ -252,7 +252,9 @@ fn draw_settings_panel(frame: &mut Frame, app: &App, area: Rect) { .border_style(Style::default().fg(theme::SURFACE2)) .title(Span::styled( " Settings ", - Style::default().fg(theme::PEACH).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::PEACH) + .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme::BASE)); let inner = block.inner(area); @@ -306,11 +308,13 @@ fn draw_settings_panel(frame: &mut Frame, app: &App, area: Rect) { Span::styled(arrows, Style::default().fg(theme::OVERLAY0)), Span::styled( format!("{:^10}", value), - Style::default().fg(value_color).add_modifier(if is_selected { - Modifier::BOLD - } else { - Modifier::empty() - }), + Style::default() + .fg(value_color) + .add_modifier(if is_selected { + Modifier::BOLD + } else { + Modifier::empty() + }), ), Span::styled(arrows_r, Style::default().fg(theme::OVERLAY0)), ]); @@ -393,7 +397,9 @@ fn draw_grid_scores(frame: &mut Frame, app: &App, area: Rect) { .border_style(Style::default().fg(theme::SURFACE2)) .title(Span::styled( " Grid Detection ", - Style::default().fg(theme::TEAL).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::TEAL) + .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme::BASE)); let inner = block.inner(area); @@ -406,7 +412,7 @@ fn draw_grid_scores(frame: &mut Frame, app: &App, area: Rect) { return; }; - if diag.grid_variance_scores.is_empty() { + if diag.grid_scores.is_empty() { let p = Paragraph::new("Grid detection was skipped") .style(Style::default().fg(theme::OVERLAY0)); frame.render_widget(p, inner); @@ -414,21 +420,26 @@ fn draw_grid_scores(frame: &mut Frame, app: &App, area: Rect) { } let max_score = diag - .grid_variance_scores + .grid_scores .iter() .map(|(_, s)| *s) .fold(0.0f32, f32::max); let best_size = diag - .grid_variance_scores + .grid_scores .iter() - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) + .max_by(|a, b| a.1.total_cmp(&b.1)) .map(|(s, _)| *s); let bar_width = inner.width.saturating_sub(16) as f32; let mut lines: Vec = Vec::new(); lines.push(Line::from("")); - for &(size, score) in &diag.grid_variance_scores { + for &(size, score) in &diag.grid_scores { + let size_label = if size.fract() == 0.0 { + format!("{}", size as u32) + } else { + format!("{:.2}", size) + }; let fill = if max_score > 0.0 { ((score / max_score) * bar_width) as usize } else { @@ -447,7 +458,7 @@ fn draw_grid_scores(frame: &mut Frame, app: &App, area: Rect) { }; lines.push(Line::from(vec![ Span::styled( - format!(" {:>2}: ", size), + format!(" {:>5}: ", size_label), Style::default().fg(theme::SUBTEXT1), ), Span::styled(bar, Style::default().fg(bar_color)), @@ -482,7 +493,9 @@ fn draw_color_histogram(frame: &mut Frame, app: &App, area: Rect) { .border_style(Style::default().fg(theme::SURFACE2)) .title(Span::styled( title, - Style::default().fg(theme::PEACH).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::PEACH) + .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme::BASE)); let inner = block.inner(area); @@ -528,20 +541,12 @@ fn draw_color_histogram(frame: &mut Frame, app: &App, area: Rect) { ), ])); if let Some(ref diag) = app.diagnostics { - if let Some(best) = diag - .grid_variance_scores - .iter() - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - { - if best.0 > 1 { - lines.push(Line::from(vec![ - Span::styled(" Logical: ", Style::default().fg(theme::SUBTEXT1)), - Span::styled( - format!("{}x{}", img.width() / best.0, img.height() / best.0), - Style::default().fg(theme::TEAL), - ), - ])); - } + if let Some(grid) = diag.grid_best_guess { + let (lw, lh) = grid.logical_size(img.width(), img.height()); + lines.push(Line::from(vec![ + Span::styled(" Logical: ", Style::default().fg(theme::SUBTEXT1)), + Span::styled(format!("{}x{}", lw, lh), Style::default().fg(theme::TEAL)), + ])); } } } @@ -593,11 +598,7 @@ fn draw_keybindings(frame: &mut Frame, app: &App, area: Rect) { ("\u{2190}\u{2192}/hl", "Adjust"), ("r", "Reset"), ], - Tab::Diagnostics => &[ - ("q", "Quit"), - ("Tab", "Next"), - ("Space", "Process"), - ], + Tab::Diagnostics => &[("q", "Quit"), ("Tab", "Next"), ("Space", "Process")], }; let mut spans: Vec = vec![Span::raw(" ")]; @@ -672,17 +673,15 @@ fn draw_text_input(frame: &mut Frame, app: &App) { Constraint::Length(1), // Hint Constraint::Length(1), // Spacer Constraint::Length(1), // Input - Constraint::Min(0), // Remaining + Constraint::Min(0), // Remaining ]) .split(inner); // Hint line - let hint = Paragraph::new(Line::from(vec![ - Span::styled( - "Enter path (~ expands to home dir):", - Style::default().fg(theme::OVERLAY0), - ), - ])); + let hint = Paragraph::new(Line::from(vec![Span::styled( + "Enter path (~ expands to home dir):", + Style::default().fg(theme::OVERLAY0), + )])); frame.render_widget(hint, rows[0]); // Input with cursor diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..038737f --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,564 @@ +//! End-to-end CLI integration tests: exit codes, JSON contract, piping, +//! naming, batch semantics, determinism. + +mod common; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn cmd() -> Command { + let mut c = Command::cargo_bin("pixfix").unwrap(); + // Tests must not pick up a developer's cwd config file. + c.arg("--no-config"); + c +} + +#[test] +fn help_and_version() { + cmd().arg("--help").assert().success(); + cmd().arg("--version").assert().success(); +} + +#[test] +fn missing_input_exits_3() { + let dir = TempDir::new().unwrap(); + cmd() + .args(["process", "/definitely/not/here.png"]) + .arg(dir.path().join("out.png")) + .assert() + .failure() + .code(3); +} + +#[test] +fn missing_explicit_config_exits_2() { + let mut c = Command::cargo_bin("pixfix").unwrap(); + c.args(["process", "x.png", "y.png", "--config", "/nope.toml"]) + .assert() + .failure() + .code(2); +} + +#[test] +fn conflicting_palette_sources_exit_2() { + cmd() + .args([ + "process", + "x.png", + "y.png", + "--palette", + "pico-8", + "--colors", + "8", + ]) + .assert() + .failure() + .code(2); +} + +#[test] +fn existing_output_exits_5() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + let output = dir.path().join("out.png"); + std::fs::write(&output, b"occupied").unwrap(); + cmd() + .arg("process") + .arg(&input) + .arg(&output) + .assert() + .failure() + .code(5); + // And the existing file was not clobbered. + assert_eq!(std::fs::read(&output).unwrap(), b"occupied"); +} + +#[test] +fn quiet_success_prints_nothing() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + cmd() + .arg("process") + .arg(&input) + .arg(dir.path().join("out.png")) + .arg("--quiet") + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); +} + +#[test] +fn process_json_contract() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + let assert = cmd() + .arg("process") + .arg(&input) + .arg(dir.path().join("out.png")) + .args(["--json", "--colors", "6", "--seed", "7"]) + .assert() + .success(); + + let doc: serde_json::Value = + serde_json::from_slice(&assert.get_output().stdout).expect("stdout is one JSON document"); + let grid = &doc["grid"]; + assert_eq!(grid["pitch_x"], 4.0); + assert_eq!(grid["source"], "detected"); + assert!(grid["confidence"].as_f64().unwrap() > 0.3); + assert_eq!(doc["logical_size"], serde_json::json!([8, 8])); + assert_eq!(doc["palette"], "auto:6"); + assert!(doc["colors_after"].as_u64().unwrap() <= 6); + assert!(doc["duration_ms"].is_u64()); + // Stable schema: keys exist even when null. + assert!(doc.get("bg_color").is_some()); + assert!(doc.get("grid_best_guess").is_some()); +} + +#[test] +fn analyze_writes_nothing() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + let before = std::fs::read_dir(dir.path()).unwrap().count(); + let assert = cmd() + .arg("analyze") + .arg(&input) + .arg("--json") + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + assert_eq!(doc["grid"]["pitch_x"], 4.0); + assert!(doc["unique_colors"].as_u64().unwrap() >= 2); + let after = std::fs::read_dir(dir.path()).unwrap().count(); + assert_eq!(before, after, "analyze must not create files"); +} + +#[test] +fn jpeg_input_produces_png_and_no_partial_file() { + let dir = TempDir::new().unwrap(); + let img = common::render_at_pitch(8, 4.0, 0.0); + let jpg = dir.path().join("photo.jpg"); + image::DynamicImage::ImageRgba8(img) + .to_rgb8() + .save(&jpg) + .unwrap(); + + cmd() + .arg("process") + .arg(&jpg) + .arg("--quiet") + .assert() + .success(); + + assert!( + dir.path().join("photo_normalized.png").exists(), + "derived output must be .png" + ); + assert!( + !dir.path().join("photo_normalized.jpg").exists(), + "no jpg output may be derived from the input extension" + ); +} + +#[test] +fn stdin_stdout_roundtrip() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + let bytes = std::fs::read(&input).unwrap(); + + let assert = cmd() + .args(["process", "-", "-", "--quiet"]) + .write_stdin(bytes) + .assert() + .success(); + let out = &assert.get_output().stdout; + let decoded = image::load_from_memory(out).expect("stdout is a decodable image"); + assert_eq!(decoded.width(), 32); +} + +#[test] +fn seeded_runs_are_byte_identical() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 12, 4.0); + let out1 = dir.path().join("a.png"); + let out2 = dir.path().join("b.png"); + for out in [&out1, &out2] { + cmd() + .arg("process") + .arg(&input) + .arg(out) + .args(["--colors", "4", "--seed", "42", "--quiet"]) + .assert() + .success(); + } + assert_eq!( + std::fs::read(&out1).unwrap(), + std::fs::read(&out2).unwrap(), + "same input + seed must produce identical bytes" + ); +} + +#[test] +fn batch_collision_rejected_and_preserve_dirs_resolves() { + let dir = TempDir::new().unwrap(); + for sub in ["a", "b"] { + std::fs::create_dir_all(dir.path().join("in").join(sub)).unwrap(); + common::write_test_image(&dir.path().join("in").join(sub), "x.png", 8, 4.0); + } + let out = dir.path().join("out"); + let pattern = format!("{}/in/**/*.png", dir.path().display()); + + cmd() + .args(["batch", &pattern]) + .arg(&out) + .arg("--quiet") + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("collision")); + + cmd() + .args(["batch", &pattern]) + .arg(&out) + .args(["--preserve-dirs", "--quiet"]) + .assert() + .success(); + assert!(out.join("a/x_normalized.png").exists()); + assert!(out.join("b/x_normalized.png").exists()); +} + +#[test] +fn batch_partial_failure_exits_6_with_itemized_report() { + let dir = TempDir::new().unwrap(); + let ind = dir.path().join("in"); + std::fs::create_dir_all(&ind).unwrap(); + common::write_test_image(&ind, "good.png", 8, 4.0); + std::fs::write(ind.join("bad.PNG"), b"not an image").unwrap(); + + let assert = cmd() + .arg("batch") + .arg(&ind) + .arg(dir.path().join("out")) + .arg("--json") + .assert() + .failure() + .code(6); + + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + assert_eq!( + doc["summary"]["total"], 2, + "case-insensitive .PNG must be picked up" + ); + assert_eq!(doc["summary"]["succeeded"], 1); + assert_eq!(doc["summary"]["failed"], 1); + let files = doc["files"].as_array().unwrap(); + assert_eq!(files.len(), 2, "every file must be itemized"); + assert!(files.iter().any(|f| f["status"] == "ok")); + assert!(files + .iter() + .any(|f| f["status"] == "failed" && f["error"].is_string())); +} + +#[test] +fn fractional_pitch_detected_end_to_end() { + let dir = TempDir::new().unwrap(); + let pitch = 128.0 / 12.0; + let path = dir.path().join("frac.png"); + common::render_at_pitch(12, pitch, 0.0).save(&path).unwrap(); + + let assert = cmd() + .arg("analyze") + .arg(&path) + .arg("--json") + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let detected = doc["grid"]["pitch_x"].as_f64().expect("grid detected"); + assert!( + (detected - pitch).abs() < 0.05, + "expected pitch ~{:.3}, got {:.3}", + pitch, + detected + ); +} + +#[test] +fn debug_overlay_written_alongside_output() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + let overlay = dir.path().join("overlay.png"); + cmd() + .arg("process") + .arg(&input) + .arg(dir.path().join("out.png")) + .arg("--debug-overlay") + .arg(&overlay) + .arg("--quiet") + .assert() + .success(); + let img = image::open(&overlay).expect("overlay is a valid image"); + assert_eq!(img.width(), 32, "overlay is at source resolution"); +} + +#[test] +fn analyze_reports_coarsen_candidates() { + // A clean pixel-perfect image: the render quantum IS the art, so rates + // must be published for 1x/2x/4x and no coarsening suggested. + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 16, 4.0); + let assert = cmd() + .arg("analyze") + .arg(&input) + .arg("--json") + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + let cands = doc["coarsen_candidates"].as_array().unwrap(); + assert_eq!(cands.len(), 3); + assert_eq!(cands[0]["factor"], 1); + assert!(cands[0]["contested_rate"].as_f64().unwrap() < 0.05); + assert!( + cands[1]["contested_rate"].as_f64().unwrap() > 0.3, + "over-coarsening a true pixel image must contest heavily" + ); + assert!(doc["suggested_coarsen"].is_null()); +} + +#[test] +fn min_confidence_floor_is_adjustable() { + // Noise scores ~0 confidence; a floor of 0 must accept its best guess. + let dir = TempDir::new().unwrap(); + let mut img = image::RgbaImage::new(64, 64); + let mut seed = 0xBADC0DEu32; + for p in img.pixels_mut() { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + *p = image::Rgba([ + (seed >> 8) as u8, + (seed >> 16) as u8, + (seed >> 24) as u8, + 255, + ]); + } + let path = dir.path().join("noise.png"); + img.save(&path).unwrap(); + + let strict = cmd() + .arg("analyze") + .arg(&path) + .arg("--json") + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&strict.get_output().stdout).unwrap(); + assert!(doc["grid"].is_null(), "default floor declines noise"); + + let loose = cmd() + .arg("analyze") + .arg(&path) + .args(["--min-confidence", "0", "--json"]) + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&loose.get_output().stdout).unwrap(); + assert!(doc["grid"].is_object(), "floor 0 accepts anything"); +} + +#[test] +fn coarsen_multiplies_detected_pitch() { + let dir = TempDir::new().unwrap(); + let input = common::write_test_image(dir.path(), "in.png", 8, 4.0); + let assert = cmd() + .arg("analyze") + .arg(&input) + .args(["--coarsen", "2", "--json"]) + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + assert_eq!(doc["grid"]["pitch_x"], 8.0, "detected 4 x coarsen 2"); + assert_eq!(doc["logical_size"], serde_json::json!([4, 4])); +} + +#[test] +fn coarsen_zero_rejected() { + cmd() + .args(["process", "x.png", "y.png", "--coarsen", "0"]) + .assert() + .failure() + .code(2); +} + +#[test] +fn chroma_key_removes_color_everywhere() { + let dir = TempDir::new().unwrap(); + // Sprite with an interior magenta region that border flood-fill could + // never reach. + let mut img = common::render_at_pitch(8, 4.0, 0.0); + for y in 12..20 { + for x in 12..20 { + img.put_pixel(x, y, image::Rgba([255, 0, 255, 255])); + } + } + let input = dir.path().join("in.png"); + img.save(&input).unwrap(); + let output = dir.path().join("out.png"); + + cmd() + .arg("process") + .arg(&input) + .arg(&output) + .args(["--chroma-key", "FF00FF", "--no-quantize", "--quiet"]) + .assert() + .success(); + + let out = image::open(&output).unwrap().to_rgba8(); + assert_eq!( + out.get_pixel(15, 15)[3], + 0, + "keyed interior must be transparent" + ); + assert_ne!(out.get_pixel(2, 2)[3], 0, "unkeyed pixels stay opaque"); +} + +#[test] +fn chroma_tolerance_requires_chroma_key() { + cmd() + .args(["process", "x.png", "y.png", "--chroma-tolerance", "0.1"]) + .assert() + .failure() + .code(2); +} + +/// Write a 3-frame animated gif: the shared synthetic sprite with a +/// 2-cell red square moving one cell per frame. +fn write_animated_gif(dir: &std::path::Path) -> std::path::PathBuf { + use image::codecs::gif::{GifEncoder, Repeat}; + use image::{Delay, Frame}; + + let path = dir.join("anim.gif"); + let file = std::fs::File::create(&path).unwrap(); + let mut encoder = GifEncoder::new(file); + encoder.set_repeat(Repeat::Infinite).unwrap(); + for i in 0..3u32 { + let mut img = common::render_at_pitch(8, 4.0, 0.0); + let x0 = 4 + i * 4; + for y in 8..16 { + for x in x0..(x0 + 8) { + img.put_pixel(x, y, image::Rgba([250, 40, 40, 255])); + } + } + let frame = Frame::from_parts(img, 0, 0, Delay::from_numer_denom_ms(100, 1)); + encoder.encode_frame(frame).unwrap(); + } + path +} + +fn decode_gif_frames(path: &std::path::Path) -> Vec { + use image::AnimationDecoder; + let bytes = std::fs::read(path).unwrap(); + image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)) + .unwrap() + .into_frames() + .collect_frames() + .unwrap() +} + +#[test] +fn animated_gif_output_is_animated_and_deterministic() { + let dir = TempDir::new().unwrap(); + let input = write_animated_gif(dir.path()); + let out1 = dir.path().join("a.gif"); + let out2 = dir.path().join("b.gif"); + for out in [&out1, &out2] { + cmd() + .arg("process") + .arg(&input) + .arg(out) + .args(["--colors", "8", "--seed", "3", "--quiet"]) + .assert() + .success(); + } + assert_eq!( + std::fs::read(&out1).unwrap(), + std::fs::read(&out2).unwrap(), + "same animated input + seed must produce identical bytes" + ); + let frames = decode_gif_frames(&out1); + assert_eq!(frames.len(), 3, "frame count must be preserved"); +} + +#[test] +fn animated_gif_rejects_non_gif_output_format() { + let dir = TempDir::new().unwrap(); + let input = write_animated_gif(dir.path()); + cmd() + .arg("process") + .arg(&input) + .arg(dir.path().join("out.webp")) + .args(["--output-format", "webp"]) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("animated GIF")); +} + +#[test] +fn animated_gif_json_reports_frames_and_derives_gif_name() { + let dir = TempDir::new().unwrap(); + let input = write_animated_gif(dir.path()); + let assert = cmd() + .arg("process") + .arg(&input) + .args(["--json", "--colors", "8", "--seed", "3"]) + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + assert_eq!(doc["frames"], 3); + assert!( + doc["output"].as_str().unwrap().ends_with("_normalized.gif"), + "derived output for an animated input must be .gif, got {}", + doc["output"] + ); + assert!(dir.path().join("anim_normalized.gif").exists()); + + // Stills carry the same always-present field with value 1. + let still = common::write_test_image(dir.path(), "still.png", 8, 4.0); + let assert = cmd() + .arg("process") + .arg(&still) + .arg(dir.path().join("still_out.png")) + .arg("--json") + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + assert_eq!(doc["frames"], 1); +} + +#[test] +fn low_confidence_noise_does_not_snap() { + let dir = TempDir::new().unwrap(); + let mut img = image::RgbaImage::new(64, 64); + let mut seed = 0xC0FFEEu32; + for p in img.pixels_mut() { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + *p = image::Rgba([ + (seed >> 8) as u8, + (seed >> 16) as u8, + (seed >> 24) as u8, + 255, + ]); + } + let path = dir.path().join("noise.png"); + img.save(&path).unwrap(); + + let assert = cmd() + .arg("analyze") + .arg(&path) + .arg("--json") + .assert() + .success(); + let doc: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); + assert!(doc["grid"].is_null(), "noise must not get a grid"); + assert!( + doc["grid_best_guess"].is_object(), + "but a best guess is reported" + ); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..dac8ea6 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,39 @@ +//! Shared helpers for CLI integration tests: synthetic image generation. + +use image::{Rgba, RgbaImage}; +use std::path::{Path, PathBuf}; + +pub const PALETTE: [Rgba; 6] = [ + Rgba([255, 0, 0, 255]), + Rgba([0, 255, 0, 255]), + Rgba([0, 0, 255, 255]), + Rgba([255, 255, 0, 255]), + Rgba([30, 30, 30, 255]), + Rgba([220, 220, 220, 255]), +]; + +/// Render a `cells x cells` logical sprite at an arbitrary (possibly +/// fractional) pitch. Deterministic pseudo-random cell colors. +pub fn render_at_pitch(cells: u32, pitch: f64, phase: f64) -> RgbaImage { + let size = (cells as f64 * pitch + phase).ceil() as u32; + let mut img = RgbaImage::from_pixel(size, size, Rgba([128, 128, 128, 255])); + for y in 0..size { + for x in 0..size { + if (x as f64) < phase || (y as f64) < phase { + continue; + } + let cx = ((x as f64 - phase) / pitch).floor() as usize; + let cy = ((y as f64 - phase) / pitch).floor() as usize; + let color = PALETTE[(cy * 31 + cx * 17 + cy * cx * 7) % PALETTE.len()]; + img.put_pixel(x, y, color); + } + } + img +} + +/// Write a synthetic test image into `dir` and return its path. +pub fn write_test_image(dir: &Path, name: &str, cells: u32, pitch: f64) -> PathBuf { + let path = dir.join(name); + render_at_pitch(cells, pitch, 0.0).save(&path).unwrap(); + path +} diff --git a/tests/corpus.rs b/tests/corpus.rs new file mode 100644 index 0000000..3cd4017 --- /dev/null +++ b/tests/corpus.rs @@ -0,0 +1,75 @@ +//! Real-image regression corpus: genuine Midjourney outputs with known +//! detection behavior. Synthetic tests can't catch perceptual regressions +//! on the inputs this tool actually exists for — these can. +//! +//! Assertions are on detection RESULTS (pitch, confidence, acceptance) +//! within tolerances, not output bytes: profile accumulation reduces +//! floats in parallel, so the last bits can differ across thread +//! schedules even though decisions are stable. + +use pixfix::pipeline::{grid_detect, GridDetectConfig, PipelineState}; + +fn load(name: &str) -> image::RgbaImage { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/corpus") + .join(name); + image::open(path).expect("corpus image").to_rgba8() +} + +fn detect(img: image::RgbaImage) -> PipelineState { + let mut state = PipelineState::new(img); + grid_detect::detect_grid(&mut state, &GridDetectConfig::default()).unwrap(); + state +} + +#[test] +fn narrowboat_detects_2px_render_quantum() { + // 1024x1024 Midjourney render, true grid ~2px. Detection must accept + // it (confidence over the 0.35 floor) at pitch 2 on both axes. + let state = detect(load("mj_narrowboat_dusk.png")); + let grid = state.grid.expect("must be accepted"); + assert!( + (grid.pitch_x - 2.0).abs() < 0.05, + "pitch_x {} drifted from the known 2px quantum", + grid.pitch_x + ); + assert!( + (grid.pitch_y - 2.0).abs() < 0.05, + "pitch_y {}", + grid.pitch_y + ); + let confidence = state.diagnostics.grid_confidence.unwrap(); + assert!( + (0.30..=0.60).contains(&confidence), + "confidence {} left its known band — detection behavior changed", + confidence + ); +} + +#[test] +fn telescope_declines_but_reconciles_axes() { + // 1456x816, dense dither + starfield + meteor streaks. Known behavior: + // detection DECLINES (confidence under the floor — the noise is real), + // but cross-axis reconciliation must keep the best guess square at + // ~2.71px; before that fix the x-axis locked its 2nd harmonic (5.41). + let state = detect(load("mj_telescope_aurora.png")); + assert!( + state.grid.is_none(), + "this image is known-noisy; silently accepting it is a regression" + ); + let guess = state + .diagnostics + .grid_best_guess + .expect("a best guess is always reported"); + assert!( + (guess.pitch_x - guess.pitch_y).abs() < 0.1, + "axes must reconcile to a square guess, got {} x {}", + guess.pitch_x, + guess.pitch_y + ); + assert!( + (2.4..=3.0).contains(&guess.pitch_x), + "best guess {} left the known ~2.71px band", + guess.pitch_x + ); +} diff --git a/tests/corpus/mj_narrowboat_dusk.png b/tests/corpus/mj_narrowboat_dusk.png new file mode 100644 index 0000000..ee9b104 Binary files /dev/null and b/tests/corpus/mj_narrowboat_dusk.png differ diff --git a/tests/corpus/mj_telescope_aurora.png b/tests/corpus/mj_telescope_aurora.png new file mode 100644 index 0000000..923e936 Binary files /dev/null and b/tests/corpus/mj_telescope_aurora.png differ diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml new file mode 100644 index 0000000..7bb3c82 --- /dev/null +++ b/wasm/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "pixfix-wasm" +version = "0.1.0" +edition = "2021" +publish = false +description = "Browser bindings for the pixfix pipeline (docs-site demo)" +license = "MIT" + +[lib] +crate-type = ["cdylib"] + +# The wasm-pack-bundled wasm-opt predates rustc enabling bulk memory by +# default; spell the post-MVP features out (and optimize for size — this +# ships to browsers). +[package.metadata.wasm-pack.profile.release] +wasm-opt = [ + "-Os", + "--enable-bulk-memory", + "--enable-sign-ext", + "--enable-mutable-globals", + "--enable-nontrapping-float-to-int", +] + +[dependencies] +# Core pipeline only: no cli, no rayon, no lospec — everything the wasm +# target can't (or shouldn't) carry. +pixfix = { path = "..", default-features = false } +image = { workspace = true, features = ["png", "jpeg"] } +wasm-bindgen = "0.2" +js-sys = "0.3" diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs new file mode 100644 index 0000000..bde6921 --- /dev/null +++ b/wasm/src/lib.rs @@ -0,0 +1,84 @@ +//! Browser bindings for the pixfix pipeline — powers the "try it" demo on +//! the docs site. One exported function: bytes in, normalized PNG + grid +//! stats out. Everything runs client-side; no image ever leaves the page. + +use js_sys::{Object, Reflect, Uint8Array}; +use pixfix::image_util::io::{encode_image, OutputFormat}; +use pixfix::pipeline::{run_pipeline, PipelineConfig}; +use wasm_bindgen::prelude::*; + +/// Keeps the demo responsive: pipeline cost grows with pixel count, and a +/// browser tab has no progress bar to hide behind. +const MAX_DIM: u32 = 2048; + +/// Normalize an image (PNG/JPEG bytes) with the default snap pipeline. +/// +/// - `colors`: quantization palette size; 0 skips quantization, otherwise +/// clamped to at least 2. +/// - `coarsen`: integer pitch multiplier (1 = off), same as `--coarsen`. +/// +/// Returns `{ png: Uint8Array, pitchX, pitchY, confidence, logicalW, +/// logicalH, accepted, guessPitch }` where the grid fields are null when +/// detection declined to snap (`accepted: false`; `guessPitch` then carries +/// the best guess, if any). +#[wasm_bindgen] +pub fn normalize(png_bytes: &[u8], colors: u32, coarsen: u32) -> Result { + let image = image::load_from_memory(png_bytes) + .map_err(|e| JsValue::from_str(&format!("could not decode image: {}", e)))? + .to_rgba8(); + + if image.width() > MAX_DIM || image.height() > MAX_DIM { + return Err(JsValue::from_str(&format!( + "image is {}x{} — the demo caps at {}x{} (the CLI has no limit)", + image.width(), + image.height(), + MAX_DIM, + MAX_DIM + ))); + } + + let mut config = PipelineConfig::default(); + config.grid.coarsen = coarsen.max(1); + config.quantize.seed = 0; + if colors > 0 { + config.quantize.num_colors = Some(colors.max(2)); + } else { + config.quantize.skip = true; + } + + let state = run_pipeline(image, &config).map_err(|e| JsValue::from_str(&e.to_string()))?; + + let png = encode_image(&state.image, OutputFormat::Png) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let d = &state.diagnostics; + let logical = state + .grid + .map(|g| g.logical_size(state.original_width, state.original_height)); + + let out = Object::new(); + let set = |key: &str, value: JsValue| { + // Reflect::set on a fresh plain object cannot fail. + let _ = Reflect::set(&out, &JsValue::from_str(key), &value); + }; + set("png", Uint8Array::from(png.as_slice()).into()); + set("pitchX", opt_f32(state.grid.map(|g| g.pitch_x))); + set("pitchY", opt_f32(state.grid.map(|g| g.pitch_y))); + set("confidence", opt_f32(d.grid_confidence)); + set("logicalW", opt_u32(logical.map(|(w, _)| w))); + set("logicalH", opt_u32(logical.map(|(_, h)| h))); + set("accepted", JsValue::from_bool(state.grid.is_some())); + set("guessPitch", opt_f32(d.grid_best_guess.map(|g| g.pitch_x))); + + Ok(out.into()) +} + +fn opt_f32(v: Option) -> JsValue { + v.map(|n| JsValue::from_f64(n as f64)) + .unwrap_or(JsValue::NULL) +} + +fn opt_u32(v: Option) -> JsValue { + v.map(|n| JsValue::from_f64(n as f64)) + .unwrap_or(JsValue::NULL) +}