├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── commands.rs ├── commands ├── gif.rs ├── icon.rs ├── optimize.rs ├── split.rs └── spritesheet.rs ├── image_util.rs ├── logger.rs ├── lua.rs └── main.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: cargo 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | target-branch: "main" 9 | commit-message: 10 | prefix: "deps(cargo)" 11 | 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | target-branch: "main" 17 | commit-message: 18 | prefix: "deps(ci)" 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | release_build: 10 | name: Build ${{ matrix.platform.os_name }} 11 | runs-on: ${{ matrix.platform.os }} 12 | strategy: 13 | matrix: 14 | platform: 15 | - os_name: Windows-x86_64 16 | os: windows-latest 17 | target: x86_64-pc-windows-gnu 18 | archiver: zip 19 | archive_type: zip 20 | bin: spritter.exe 21 | - os_name: Linux-x86_64 22 | os: ubuntu-latest 23 | target: x86_64-unknown-linux-gnu 24 | archiver: tar 25 | archive_type: tar.gz 26 | bin: spritter 27 | - os_name: MacOS-x86_64 28 | os: macos-latest 29 | target: x86_64-apple-darwin 30 | archiver: tar 31 | archive_type: tar.gz 32 | bin: spritter 33 | - os_name: MacOS-aarch64 34 | os: macos-latest 35 | target: aarch64-apple-darwin 36 | archiver: tar 37 | archive_type: tar.gz 38 | bin: spritter 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | - name: Stable rust toolchain 43 | run: rustup toolchain install stable --target ${{ matrix.platform.target }} --profile minimal 44 | - name: Rust cache 45 | uses: Swatinem/rust-cache@v2 46 | with: 47 | prefix-key: "rust-stable" 48 | shared-key: "release_build-${{ matrix.platform.target }}" 49 | - name: Build 50 | run: cargo build --target ${{ matrix.platform.target }} --release --package spritter 51 | - name: Archive the built binary 52 | uses: TheDoctor0/zip-release@0.7.6 53 | with: 54 | type: ${{ matrix.platform.archiver }} 55 | filename: spritter_${{ matrix.platform.target }}.${{ matrix.platform.archive_type }} 56 | directory: ./target/${{ matrix.platform.target }}/release 57 | path: ${{ matrix.platform.bin }} 58 | - name: Upload release artifact for release job 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: release_${{ matrix.platform.target }} 62 | path: ./target/${{ matrix.platform.target }}/release/spritter_${{ matrix.platform.target }}.${{ matrix.platform.archive_type }} 63 | create_release: 64 | name: Create release 65 | needs: release_build 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Download release artifacts from build job 69 | uses: actions/download-artifact@v4 70 | with: 71 | path: ~/build_artifacts 72 | - name: Create release 73 | id: create_release 74 | uses: ncipollo/release-action@v1 75 | with: 76 | artifacts: ~/build_artifacts/release_*/* 77 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: rust 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - synchronize 9 | - ready_for_review 10 | push: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | fmt: 16 | name: Check formatting 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Stable rust toolchain 22 | run: rustup toolchain install stable --profile minimal 23 | - name: Check formatting 24 | run: cargo fmt --all -- --check 25 | 26 | clippy: 27 | name: Clippy 28 | runs-on: ubuntu-latest 29 | if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }} 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | - name: Stable rust toolchain 34 | run: rustup toolchain install stable --profile minimal 35 | - name: Add Clippy 36 | run: rustup component add clippy 37 | - name: Rust cache 38 | uses: Swatinem/rust-cache@v2 39 | with: 40 | prefix-key: "rust-stable" 41 | shared-key: "clippy" 42 | - name: Install cargo-binstall 43 | run: wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-gnu.tgz -O - | tar -xz -C $HOME/.cargo/bin 44 | - name: Install sarif-fmt & clippy-sarif 45 | run: cargo binstall --no-confirm --force sarif-fmt clippy-sarif 46 | - name: Run Clippy 47 | run: cargo clippy --message-format=json | clippy-sarif | tee results.sarif | sarif-fmt 48 | - name: Upload SARIF file 49 | uses: github/codeql-action/upload-sarif@v3 50 | with: 51 | sarif_file: results.sarif 52 | 53 | build: 54 | name: Build ${{ matrix.platform.os_name }} [rust ${{ matrix.toolchain }}] 55 | runs-on: ${{ matrix.platform.os }} 56 | if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }} 57 | strategy: 58 | matrix: 59 | platform: 60 | - os_name: Windows-x86_64 61 | os: windows-latest 62 | target: x86_64-pc-windows-gnu 63 | - os_name: Linux-x86_64 64 | os: ubuntu-latest 65 | target: x86_64-unknown-linux-gnu 66 | - os_name: MacOS-x86_64 67 | os: macos-latest 68 | target: x86_64-apple-darwin 69 | - os_name: MacOS-aarch64 70 | os: macos-latest 71 | target: aarch64-apple-darwin 72 | toolchain: 73 | - stable 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v4 77 | - name: Setup ${{ matrix.toolchain }} toolchain 78 | run: rustup toolchain install ${{ matrix.toolchain }} --target ${{ matrix.platform.target }} --profile minimal 79 | - name: Rust cache 80 | uses: Swatinem/rust-cache@v2 81 | with: 82 | prefix-key: "rust-${{ matrix.toolchain }}" 83 | shared-key: "build-${{ matrix.platform.target }}" 84 | - name: Build 85 | run: cargo build --target ${{ matrix.platform.target }} 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | /target 3 | 4 | test/ 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "adler2" 7 | version = "2.0.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 10 | 11 | [[package]] 12 | name = "aho-corasick" 13 | version = "1.1.3" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 16 | dependencies = [ 17 | "memchr", 18 | ] 19 | 20 | [[package]] 21 | name = "aligned-vec" 22 | version = "0.5.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" 25 | 26 | [[package]] 27 | name = "anstream" 28 | version = "0.6.18" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 31 | dependencies = [ 32 | "anstyle", 33 | "anstyle-parse", 34 | "anstyle-query", 35 | "anstyle-wincon", 36 | "colorchoice", 37 | "is_terminal_polyfill", 38 | "utf8parse", 39 | ] 40 | 41 | [[package]] 42 | name = "anstyle" 43 | version = "1.0.10" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 46 | 47 | [[package]] 48 | name = "anstyle-parse" 49 | version = "0.2.6" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 52 | dependencies = [ 53 | "utf8parse", 54 | ] 55 | 56 | [[package]] 57 | name = "anstyle-query" 58 | version = "1.1.2" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 61 | dependencies = [ 62 | "windows-sys", 63 | ] 64 | 65 | [[package]] 66 | name = "anstyle-wincon" 67 | version = "3.0.7" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 70 | dependencies = [ 71 | "anstyle", 72 | "once_cell", 73 | "windows-sys", 74 | ] 75 | 76 | [[package]] 77 | name = "anyhow" 78 | version = "1.0.98" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 81 | 82 | [[package]] 83 | name = "arbitrary" 84 | version = "1.4.1" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" 87 | 88 | [[package]] 89 | name = "arg_enum_proc_macro" 90 | version = "0.3.4" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" 93 | dependencies = [ 94 | "proc-macro2", 95 | "quote", 96 | "syn", 97 | ] 98 | 99 | [[package]] 100 | name = "arrayvec" 101 | version = "0.7.6" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 104 | 105 | [[package]] 106 | name = "autocfg" 107 | version = "1.4.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 110 | 111 | [[package]] 112 | name = "av1-grain" 113 | version = "0.2.3" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" 116 | dependencies = [ 117 | "anyhow", 118 | "arrayvec", 119 | "log", 120 | "nom", 121 | "num-rational", 122 | "v_frame", 123 | ] 124 | 125 | [[package]] 126 | name = "avif-serialize" 127 | version = "0.8.3" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" 130 | dependencies = [ 131 | "arrayvec", 132 | ] 133 | 134 | [[package]] 135 | name = "bit_field" 136 | version = "0.10.2" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" 139 | 140 | [[package]] 141 | name = "bitflags" 142 | version = "1.3.2" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 145 | 146 | [[package]] 147 | name = "bitflags" 148 | version = "2.9.0" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 151 | 152 | [[package]] 153 | name = "bitstream-io" 154 | version = "2.6.0" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" 157 | 158 | [[package]] 159 | name = "bitvec" 160 | version = "1.0.1" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" 163 | dependencies = [ 164 | "funty", 165 | "radium", 166 | "tap", 167 | "wyz", 168 | ] 169 | 170 | [[package]] 171 | name = "built" 172 | version = "0.7.7" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" 175 | 176 | [[package]] 177 | name = "bumpalo" 178 | version = "3.17.0" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 181 | 182 | [[package]] 183 | name = "bytemuck" 184 | version = "1.22.0" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" 187 | 188 | [[package]] 189 | name = "byteorder-lite" 190 | version = "0.1.0" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" 193 | 194 | [[package]] 195 | name = "cc" 196 | version = "1.2.19" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" 199 | dependencies = [ 200 | "jobserver", 201 | "libc", 202 | "shlex", 203 | ] 204 | 205 | [[package]] 206 | name = "cfg-expr" 207 | version = "0.15.8" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" 210 | dependencies = [ 211 | "smallvec", 212 | "target-lexicon", 213 | ] 214 | 215 | [[package]] 216 | name = "cfg-if" 217 | version = "1.0.0" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 220 | 221 | [[package]] 222 | name = "clap" 223 | version = "4.5.37" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" 226 | dependencies = [ 227 | "clap_builder", 228 | "clap_derive", 229 | ] 230 | 231 | [[package]] 232 | name = "clap_builder" 233 | version = "4.5.37" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" 236 | dependencies = [ 237 | "anstream", 238 | "anstyle", 239 | "clap_lex", 240 | "strsim", 241 | ] 242 | 243 | [[package]] 244 | name = "clap_derive" 245 | version = "4.5.32" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 248 | dependencies = [ 249 | "heck", 250 | "proc-macro2", 251 | "quote", 252 | "syn", 253 | ] 254 | 255 | [[package]] 256 | name = "clap_lex" 257 | version = "0.7.4" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 260 | 261 | [[package]] 262 | name = "color_quant" 263 | version = "1.1.0" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 266 | 267 | [[package]] 268 | name = "colorchoice" 269 | version = "1.0.3" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 272 | 273 | [[package]] 274 | name = "crc32fast" 275 | version = "1.4.2" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" 278 | dependencies = [ 279 | "cfg-if", 280 | ] 281 | 282 | [[package]] 283 | name = "crossbeam-channel" 284 | version = "0.5.15" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 287 | dependencies = [ 288 | "crossbeam-utils", 289 | ] 290 | 291 | [[package]] 292 | name = "crossbeam-deque" 293 | version = "0.8.6" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 296 | dependencies = [ 297 | "crossbeam-epoch", 298 | "crossbeam-utils", 299 | ] 300 | 301 | [[package]] 302 | name = "crossbeam-epoch" 303 | version = "0.9.18" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 306 | dependencies = [ 307 | "crossbeam-utils", 308 | ] 309 | 310 | [[package]] 311 | name = "crossbeam-utils" 312 | version = "0.8.21" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 315 | 316 | [[package]] 317 | name = "crunchy" 318 | version = "0.2.3" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" 321 | 322 | [[package]] 323 | name = "either" 324 | version = "1.15.0" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 327 | 328 | [[package]] 329 | name = "env_logger" 330 | version = "0.10.2" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" 333 | dependencies = [ 334 | "humantime", 335 | "is-terminal", 336 | "log", 337 | "regex", 338 | "termcolor", 339 | ] 340 | 341 | [[package]] 342 | name = "equivalent" 343 | version = "1.0.2" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 346 | 347 | [[package]] 348 | name = "exr" 349 | version = "1.73.0" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" 352 | dependencies = [ 353 | "bit_field", 354 | "half", 355 | "lebe", 356 | "miniz_oxide", 357 | "rayon-core", 358 | "smallvec", 359 | "zune-inflate", 360 | ] 361 | 362 | [[package]] 363 | name = "fdeflate" 364 | version = "0.3.7" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 367 | dependencies = [ 368 | "simd-adler32", 369 | ] 370 | 371 | [[package]] 372 | name = "flate2" 373 | version = "1.1.1" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" 376 | dependencies = [ 377 | "crc32fast", 378 | "miniz_oxide", 379 | ] 380 | 381 | [[package]] 382 | name = "funty" 383 | version = "2.0.0" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" 386 | 387 | [[package]] 388 | name = "getrandom" 389 | version = "0.2.16" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 392 | dependencies = [ 393 | "cfg-if", 394 | "libc", 395 | "wasi 0.11.0+wasi-snapshot-preview1", 396 | ] 397 | 398 | [[package]] 399 | name = "getrandom" 400 | version = "0.3.2" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" 403 | dependencies = [ 404 | "cfg-if", 405 | "libc", 406 | "r-efi", 407 | "wasi 0.14.2+wasi-0.2.4", 408 | ] 409 | 410 | [[package]] 411 | name = "gif" 412 | version = "0.13.1" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" 415 | dependencies = [ 416 | "color_quant", 417 | "weezl", 418 | ] 419 | 420 | [[package]] 421 | name = "half" 422 | version = "2.6.0" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" 425 | dependencies = [ 426 | "cfg-if", 427 | "crunchy", 428 | ] 429 | 430 | [[package]] 431 | name = "hashbrown" 432 | version = "0.15.2" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 435 | 436 | [[package]] 437 | name = "heck" 438 | version = "0.5.0" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 441 | 442 | [[package]] 443 | name = "hermit-abi" 444 | version = "0.5.0" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" 447 | 448 | [[package]] 449 | name = "humantime" 450 | version = "2.2.0" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" 453 | 454 | [[package]] 455 | name = "image" 456 | version = "0.25.6" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" 459 | dependencies = [ 460 | "bytemuck", 461 | "byteorder-lite", 462 | "color_quant", 463 | "exr", 464 | "gif", 465 | "image-webp", 466 | "num-traits", 467 | "png", 468 | "qoi", 469 | "ravif", 470 | "rayon", 471 | "rgb", 472 | "tiff", 473 | "zune-core", 474 | "zune-jpeg", 475 | ] 476 | 477 | [[package]] 478 | name = "image-webp" 479 | version = "0.2.1" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" 482 | dependencies = [ 483 | "byteorder-lite", 484 | "quick-error", 485 | ] 486 | 487 | [[package]] 488 | name = "imagequant" 489 | version = "4.3.4" 490 | source = "registry+https://github.com/rust-lang/crates.io-index" 491 | checksum = "1cede25dbe6a6c3842989fa341cba6e2a4dc33ba12a33f553baeed257f965cb5" 492 | dependencies = [ 493 | "arrayvec", 494 | "once_cell", 495 | "rayon", 496 | "rgb", 497 | "thread_local", 498 | ] 499 | 500 | [[package]] 501 | name = "imgref" 502 | version = "1.11.0" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" 505 | 506 | [[package]] 507 | name = "indexmap" 508 | version = "2.9.0" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 511 | dependencies = [ 512 | "equivalent", 513 | "hashbrown", 514 | "rayon", 515 | ] 516 | 517 | [[package]] 518 | name = "interpolate_name" 519 | version = "0.2.4" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" 522 | dependencies = [ 523 | "proc-macro2", 524 | "quote", 525 | "syn", 526 | ] 527 | 528 | [[package]] 529 | name = "is-terminal" 530 | version = "0.4.16" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" 533 | dependencies = [ 534 | "hermit-abi", 535 | "libc", 536 | "windows-sys", 537 | ] 538 | 539 | [[package]] 540 | name = "is_terminal_polyfill" 541 | version = "1.70.1" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 544 | 545 | [[package]] 546 | name = "itertools" 547 | version = "0.12.1" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 550 | dependencies = [ 551 | "either", 552 | ] 553 | 554 | [[package]] 555 | name = "jobserver" 556 | version = "0.1.33" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" 559 | dependencies = [ 560 | "getrandom 0.3.2", 561 | "libc", 562 | ] 563 | 564 | [[package]] 565 | name = "jpeg-decoder" 566 | version = "0.3.1" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" 569 | 570 | [[package]] 571 | name = "lebe" 572 | version = "0.5.2" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" 575 | 576 | [[package]] 577 | name = "libc" 578 | version = "0.2.172" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 581 | 582 | [[package]] 583 | name = "libdeflate-sys" 584 | version = "1.23.1" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "38b72ad3fbf5ac78f2df7b36075e48adf2459b57c150b9e63937d0204d0f9cd7" 587 | dependencies = [ 588 | "cc", 589 | ] 590 | 591 | [[package]] 592 | name = "libdeflater" 593 | version = "1.23.1" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "013344b17f9dceddff4872559ae19378bd8ee0479eccdd266d2dd2e894b4792f" 596 | dependencies = [ 597 | "libdeflate-sys", 598 | ] 599 | 600 | [[package]] 601 | name = "libfuzzer-sys" 602 | version = "0.4.9" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" 605 | dependencies = [ 606 | "arbitrary", 607 | "cc", 608 | ] 609 | 610 | [[package]] 611 | name = "log" 612 | version = "0.4.27" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 615 | 616 | [[package]] 617 | name = "loop9" 618 | version = "0.1.5" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" 621 | dependencies = [ 622 | "imgref", 623 | ] 624 | 625 | [[package]] 626 | name = "maybe-rayon" 627 | version = "0.1.1" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" 630 | dependencies = [ 631 | "cfg-if", 632 | "rayon", 633 | ] 634 | 635 | [[package]] 636 | name = "memchr" 637 | version = "2.7.4" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 640 | 641 | [[package]] 642 | name = "minimal-lexical" 643 | version = "0.2.1" 644 | source = "registry+https://github.com/rust-lang/crates.io-index" 645 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 646 | 647 | [[package]] 648 | name = "miniz_oxide" 649 | version = "0.8.8" 650 | source = "registry+https://github.com/rust-lang/crates.io-index" 651 | checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 652 | dependencies = [ 653 | "adler2", 654 | "simd-adler32", 655 | ] 656 | 657 | [[package]] 658 | name = "natord" 659 | version = "1.0.9" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" 662 | 663 | [[package]] 664 | name = "new_debug_unreachable" 665 | version = "1.0.6" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 668 | 669 | [[package]] 670 | name = "nom" 671 | version = "7.1.3" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 674 | dependencies = [ 675 | "memchr", 676 | "minimal-lexical", 677 | ] 678 | 679 | [[package]] 680 | name = "noop_proc_macro" 681 | version = "0.3.0" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 684 | 685 | [[package]] 686 | name = "num-bigint" 687 | version = "0.4.6" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 690 | dependencies = [ 691 | "num-integer", 692 | "num-traits", 693 | ] 694 | 695 | [[package]] 696 | name = "num-derive" 697 | version = "0.4.2" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" 700 | dependencies = [ 701 | "proc-macro2", 702 | "quote", 703 | "syn", 704 | ] 705 | 706 | [[package]] 707 | name = "num-integer" 708 | version = "0.1.46" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 711 | dependencies = [ 712 | "num-traits", 713 | ] 714 | 715 | [[package]] 716 | name = "num-rational" 717 | version = "0.4.2" 718 | source = "registry+https://github.com/rust-lang/crates.io-index" 719 | checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" 720 | dependencies = [ 721 | "num-bigint", 722 | "num-integer", 723 | "num-traits", 724 | ] 725 | 726 | [[package]] 727 | name = "num-traits" 728 | version = "0.2.19" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 731 | dependencies = [ 732 | "autocfg", 733 | ] 734 | 735 | [[package]] 736 | name = "once_cell" 737 | version = "1.21.3" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 740 | 741 | [[package]] 742 | name = "oxipng" 743 | version = "9.1.4" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "3bce05680d3f2ec3f0510f19608d56712fa7ea681b4ba293c3b74a04c2e55279" 746 | dependencies = [ 747 | "bitvec", 748 | "crossbeam-channel", 749 | "indexmap", 750 | "libdeflater", 751 | "log", 752 | "rayon", 753 | "rgb", 754 | "rustc-hash", 755 | ] 756 | 757 | [[package]] 758 | name = "paste" 759 | version = "1.0.15" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 762 | 763 | [[package]] 764 | name = "pkg-config" 765 | version = "0.3.32" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 768 | 769 | [[package]] 770 | name = "png" 771 | version = "0.17.16" 772 | source = "registry+https://github.com/rust-lang/crates.io-index" 773 | checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" 774 | dependencies = [ 775 | "bitflags 1.3.2", 776 | "crc32fast", 777 | "fdeflate", 778 | "flate2", 779 | "miniz_oxide", 780 | ] 781 | 782 | [[package]] 783 | name = "ppv-lite86" 784 | version = "0.2.21" 785 | source = "registry+https://github.com/rust-lang/crates.io-index" 786 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 787 | dependencies = [ 788 | "zerocopy", 789 | ] 790 | 791 | [[package]] 792 | name = "proc-macro2" 793 | version = "1.0.95" 794 | source = "registry+https://github.com/rust-lang/crates.io-index" 795 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 796 | dependencies = [ 797 | "unicode-ident", 798 | ] 799 | 800 | [[package]] 801 | name = "profiling" 802 | version = "1.0.16" 803 | source = "registry+https://github.com/rust-lang/crates.io-index" 804 | checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" 805 | dependencies = [ 806 | "profiling-procmacros", 807 | ] 808 | 809 | [[package]] 810 | name = "profiling-procmacros" 811 | version = "1.0.16" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" 814 | dependencies = [ 815 | "quote", 816 | "syn", 817 | ] 818 | 819 | [[package]] 820 | name = "qoi" 821 | version = "0.4.1" 822 | source = "registry+https://github.com/rust-lang/crates.io-index" 823 | checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" 824 | dependencies = [ 825 | "bytemuck", 826 | ] 827 | 828 | [[package]] 829 | name = "quick-error" 830 | version = "2.0.1" 831 | source = "registry+https://github.com/rust-lang/crates.io-index" 832 | checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 833 | 834 | [[package]] 835 | name = "quote" 836 | version = "1.0.40" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 839 | dependencies = [ 840 | "proc-macro2", 841 | ] 842 | 843 | [[package]] 844 | name = "r-efi" 845 | version = "5.2.0" 846 | source = "registry+https://github.com/rust-lang/crates.io-index" 847 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 848 | 849 | [[package]] 850 | name = "radium" 851 | version = "0.7.0" 852 | source = "registry+https://github.com/rust-lang/crates.io-index" 853 | checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" 854 | 855 | [[package]] 856 | name = "rand" 857 | version = "0.8.5" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 860 | dependencies = [ 861 | "libc", 862 | "rand_chacha", 863 | "rand_core", 864 | ] 865 | 866 | [[package]] 867 | name = "rand_chacha" 868 | version = "0.3.1" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 871 | dependencies = [ 872 | "ppv-lite86", 873 | "rand_core", 874 | ] 875 | 876 | [[package]] 877 | name = "rand_core" 878 | version = "0.6.4" 879 | source = "registry+https://github.com/rust-lang/crates.io-index" 880 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 881 | dependencies = [ 882 | "getrandom 0.2.16", 883 | ] 884 | 885 | [[package]] 886 | name = "rav1e" 887 | version = "0.7.1" 888 | source = "registry+https://github.com/rust-lang/crates.io-index" 889 | checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" 890 | dependencies = [ 891 | "arbitrary", 892 | "arg_enum_proc_macro", 893 | "arrayvec", 894 | "av1-grain", 895 | "bitstream-io", 896 | "built", 897 | "cfg-if", 898 | "interpolate_name", 899 | "itertools", 900 | "libc", 901 | "libfuzzer-sys", 902 | "log", 903 | "maybe-rayon", 904 | "new_debug_unreachable", 905 | "noop_proc_macro", 906 | "num-derive", 907 | "num-traits", 908 | "once_cell", 909 | "paste", 910 | "profiling", 911 | "rand", 912 | "rand_chacha", 913 | "simd_helpers", 914 | "system-deps", 915 | "thiserror 1.0.69", 916 | "v_frame", 917 | "wasm-bindgen", 918 | ] 919 | 920 | [[package]] 921 | name = "ravif" 922 | version = "0.11.12" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "d6a5f31fcf7500f9401fea858ea4ab5525c99f2322cfcee732c0e6c74208c0c6" 925 | dependencies = [ 926 | "avif-serialize", 927 | "imgref", 928 | "loop9", 929 | "quick-error", 930 | "rav1e", 931 | "rayon", 932 | "rgb", 933 | ] 934 | 935 | [[package]] 936 | name = "rayon" 937 | version = "1.10.0" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 940 | dependencies = [ 941 | "either", 942 | "rayon-core", 943 | ] 944 | 945 | [[package]] 946 | name = "rayon-core" 947 | version = "1.12.1" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 950 | dependencies = [ 951 | "crossbeam-deque", 952 | "crossbeam-utils", 953 | ] 954 | 955 | [[package]] 956 | name = "regex" 957 | version = "1.11.1" 958 | source = "registry+https://github.com/rust-lang/crates.io-index" 959 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 960 | dependencies = [ 961 | "aho-corasick", 962 | "memchr", 963 | "regex-automata", 964 | "regex-syntax", 965 | ] 966 | 967 | [[package]] 968 | name = "regex-automata" 969 | version = "0.4.9" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 972 | dependencies = [ 973 | "aho-corasick", 974 | "memchr", 975 | "regex-syntax", 976 | ] 977 | 978 | [[package]] 979 | name = "regex-syntax" 980 | version = "0.8.5" 981 | source = "registry+https://github.com/rust-lang/crates.io-index" 982 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 983 | 984 | [[package]] 985 | name = "rgb" 986 | version = "0.8.50" 987 | source = "registry+https://github.com/rust-lang/crates.io-index" 988 | checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" 989 | dependencies = [ 990 | "bytemuck", 991 | ] 992 | 993 | [[package]] 994 | name = "rustc-hash" 995 | version = "2.1.1" 996 | source = "registry+https://github.com/rust-lang/crates.io-index" 997 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 998 | 999 | [[package]] 1000 | name = "rustversion" 1001 | version = "1.0.20" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 1004 | 1005 | [[package]] 1006 | name = "serde" 1007 | version = "1.0.219" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1010 | dependencies = [ 1011 | "serde_derive", 1012 | ] 1013 | 1014 | [[package]] 1015 | name = "serde_derive" 1016 | version = "1.0.219" 1017 | source = "registry+https://github.com/rust-lang/crates.io-index" 1018 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1019 | dependencies = [ 1020 | "proc-macro2", 1021 | "quote", 1022 | "syn", 1023 | ] 1024 | 1025 | [[package]] 1026 | name = "serde_spanned" 1027 | version = "0.6.8" 1028 | source = "registry+https://github.com/rust-lang/crates.io-index" 1029 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 1030 | dependencies = [ 1031 | "serde", 1032 | ] 1033 | 1034 | [[package]] 1035 | name = "shlex" 1036 | version = "1.3.0" 1037 | source = "registry+https://github.com/rust-lang/crates.io-index" 1038 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1039 | 1040 | [[package]] 1041 | name = "simd-adler32" 1042 | version = "0.3.7" 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" 1044 | checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 1045 | 1046 | [[package]] 1047 | name = "simd_helpers" 1048 | version = "0.1.0" 1049 | source = "registry+https://github.com/rust-lang/crates.io-index" 1050 | checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" 1051 | dependencies = [ 1052 | "quote", 1053 | ] 1054 | 1055 | [[package]] 1056 | name = "smallvec" 1057 | version = "1.15.0" 1058 | source = "registry+https://github.com/rust-lang/crates.io-index" 1059 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 1060 | 1061 | [[package]] 1062 | name = "spritter" 1063 | version = "1.8.0" 1064 | dependencies = [ 1065 | "clap", 1066 | "env_logger", 1067 | "image", 1068 | "imagequant", 1069 | "log", 1070 | "natord", 1071 | "oxipng", 1072 | "rayon", 1073 | "strum", 1074 | "thiserror 2.0.12", 1075 | ] 1076 | 1077 | [[package]] 1078 | name = "strsim" 1079 | version = "0.11.1" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1082 | 1083 | [[package]] 1084 | name = "strum" 1085 | version = "0.27.1" 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" 1087 | checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" 1088 | dependencies = [ 1089 | "strum_macros", 1090 | ] 1091 | 1092 | [[package]] 1093 | name = "strum_macros" 1094 | version = "0.27.1" 1095 | source = "registry+https://github.com/rust-lang/crates.io-index" 1096 | checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" 1097 | dependencies = [ 1098 | "heck", 1099 | "proc-macro2", 1100 | "quote", 1101 | "rustversion", 1102 | "syn", 1103 | ] 1104 | 1105 | [[package]] 1106 | name = "syn" 1107 | version = "2.0.100" 1108 | source = "registry+https://github.com/rust-lang/crates.io-index" 1109 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 1110 | dependencies = [ 1111 | "proc-macro2", 1112 | "quote", 1113 | "unicode-ident", 1114 | ] 1115 | 1116 | [[package]] 1117 | name = "system-deps" 1118 | version = "6.2.2" 1119 | source = "registry+https://github.com/rust-lang/crates.io-index" 1120 | checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" 1121 | dependencies = [ 1122 | "cfg-expr", 1123 | "heck", 1124 | "pkg-config", 1125 | "toml", 1126 | "version-compare", 1127 | ] 1128 | 1129 | [[package]] 1130 | name = "tap" 1131 | version = "1.0.1" 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" 1133 | checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 1134 | 1135 | [[package]] 1136 | name = "target-lexicon" 1137 | version = "0.12.16" 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" 1139 | checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" 1140 | 1141 | [[package]] 1142 | name = "termcolor" 1143 | version = "1.4.1" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 1146 | dependencies = [ 1147 | "winapi-util", 1148 | ] 1149 | 1150 | [[package]] 1151 | name = "thiserror" 1152 | version = "1.0.69" 1153 | source = "registry+https://github.com/rust-lang/crates.io-index" 1154 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1155 | dependencies = [ 1156 | "thiserror-impl 1.0.69", 1157 | ] 1158 | 1159 | [[package]] 1160 | name = "thiserror" 1161 | version = "2.0.12" 1162 | source = "registry+https://github.com/rust-lang/crates.io-index" 1163 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 1164 | dependencies = [ 1165 | "thiserror-impl 2.0.12", 1166 | ] 1167 | 1168 | [[package]] 1169 | name = "thiserror-impl" 1170 | version = "1.0.69" 1171 | source = "registry+https://github.com/rust-lang/crates.io-index" 1172 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1173 | dependencies = [ 1174 | "proc-macro2", 1175 | "quote", 1176 | "syn", 1177 | ] 1178 | 1179 | [[package]] 1180 | name = "thiserror-impl" 1181 | version = "2.0.12" 1182 | source = "registry+https://github.com/rust-lang/crates.io-index" 1183 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 1184 | dependencies = [ 1185 | "proc-macro2", 1186 | "quote", 1187 | "syn", 1188 | ] 1189 | 1190 | [[package]] 1191 | name = "thread_local" 1192 | version = "1.1.8" 1193 | source = "registry+https://github.com/rust-lang/crates.io-index" 1194 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 1195 | dependencies = [ 1196 | "cfg-if", 1197 | "once_cell", 1198 | ] 1199 | 1200 | [[package]] 1201 | name = "tiff" 1202 | version = "0.9.1" 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" 1204 | checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" 1205 | dependencies = [ 1206 | "flate2", 1207 | "jpeg-decoder", 1208 | "weezl", 1209 | ] 1210 | 1211 | [[package]] 1212 | name = "toml" 1213 | version = "0.8.20" 1214 | source = "registry+https://github.com/rust-lang/crates.io-index" 1215 | checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" 1216 | dependencies = [ 1217 | "serde", 1218 | "serde_spanned", 1219 | "toml_datetime", 1220 | "toml_edit", 1221 | ] 1222 | 1223 | [[package]] 1224 | name = "toml_datetime" 1225 | version = "0.6.8" 1226 | source = "registry+https://github.com/rust-lang/crates.io-index" 1227 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 1228 | dependencies = [ 1229 | "serde", 1230 | ] 1231 | 1232 | [[package]] 1233 | name = "toml_edit" 1234 | version = "0.22.24" 1235 | source = "registry+https://github.com/rust-lang/crates.io-index" 1236 | checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" 1237 | dependencies = [ 1238 | "indexmap", 1239 | "serde", 1240 | "serde_spanned", 1241 | "toml_datetime", 1242 | "winnow", 1243 | ] 1244 | 1245 | [[package]] 1246 | name = "unicode-ident" 1247 | version = "1.0.18" 1248 | source = "registry+https://github.com/rust-lang/crates.io-index" 1249 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 1250 | 1251 | [[package]] 1252 | name = "utf8parse" 1253 | version = "0.2.2" 1254 | source = "registry+https://github.com/rust-lang/crates.io-index" 1255 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1256 | 1257 | [[package]] 1258 | name = "v_frame" 1259 | version = "0.3.8" 1260 | source = "registry+https://github.com/rust-lang/crates.io-index" 1261 | checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" 1262 | dependencies = [ 1263 | "aligned-vec", 1264 | "num-traits", 1265 | "wasm-bindgen", 1266 | ] 1267 | 1268 | [[package]] 1269 | name = "version-compare" 1270 | version = "0.2.0" 1271 | source = "registry+https://github.com/rust-lang/crates.io-index" 1272 | checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" 1273 | 1274 | [[package]] 1275 | name = "wasi" 1276 | version = "0.11.0+wasi-snapshot-preview1" 1277 | source = "registry+https://github.com/rust-lang/crates.io-index" 1278 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1279 | 1280 | [[package]] 1281 | name = "wasi" 1282 | version = "0.14.2+wasi-0.2.4" 1283 | source = "registry+https://github.com/rust-lang/crates.io-index" 1284 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 1285 | dependencies = [ 1286 | "wit-bindgen-rt", 1287 | ] 1288 | 1289 | [[package]] 1290 | name = "wasm-bindgen" 1291 | version = "0.2.100" 1292 | source = "registry+https://github.com/rust-lang/crates.io-index" 1293 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1294 | dependencies = [ 1295 | "cfg-if", 1296 | "once_cell", 1297 | "rustversion", 1298 | "wasm-bindgen-macro", 1299 | ] 1300 | 1301 | [[package]] 1302 | name = "wasm-bindgen-backend" 1303 | version = "0.2.100" 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" 1305 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1306 | dependencies = [ 1307 | "bumpalo", 1308 | "log", 1309 | "proc-macro2", 1310 | "quote", 1311 | "syn", 1312 | "wasm-bindgen-shared", 1313 | ] 1314 | 1315 | [[package]] 1316 | name = "wasm-bindgen-macro" 1317 | version = "0.2.100" 1318 | source = "registry+https://github.com/rust-lang/crates.io-index" 1319 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1320 | dependencies = [ 1321 | "quote", 1322 | "wasm-bindgen-macro-support", 1323 | ] 1324 | 1325 | [[package]] 1326 | name = "wasm-bindgen-macro-support" 1327 | version = "0.2.100" 1328 | source = "registry+https://github.com/rust-lang/crates.io-index" 1329 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1330 | dependencies = [ 1331 | "proc-macro2", 1332 | "quote", 1333 | "syn", 1334 | "wasm-bindgen-backend", 1335 | "wasm-bindgen-shared", 1336 | ] 1337 | 1338 | [[package]] 1339 | name = "wasm-bindgen-shared" 1340 | version = "0.2.100" 1341 | source = "registry+https://github.com/rust-lang/crates.io-index" 1342 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1343 | dependencies = [ 1344 | "unicode-ident", 1345 | ] 1346 | 1347 | [[package]] 1348 | name = "weezl" 1349 | version = "0.1.8" 1350 | source = "registry+https://github.com/rust-lang/crates.io-index" 1351 | checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" 1352 | 1353 | [[package]] 1354 | name = "winapi-util" 1355 | version = "0.1.9" 1356 | source = "registry+https://github.com/rust-lang/crates.io-index" 1357 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 1358 | dependencies = [ 1359 | "windows-sys", 1360 | ] 1361 | 1362 | [[package]] 1363 | name = "windows-sys" 1364 | version = "0.59.0" 1365 | source = "registry+https://github.com/rust-lang/crates.io-index" 1366 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1367 | dependencies = [ 1368 | "windows-targets", 1369 | ] 1370 | 1371 | [[package]] 1372 | name = "windows-targets" 1373 | version = "0.52.6" 1374 | source = "registry+https://github.com/rust-lang/crates.io-index" 1375 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1376 | dependencies = [ 1377 | "windows_aarch64_gnullvm", 1378 | "windows_aarch64_msvc", 1379 | "windows_i686_gnu", 1380 | "windows_i686_gnullvm", 1381 | "windows_i686_msvc", 1382 | "windows_x86_64_gnu", 1383 | "windows_x86_64_gnullvm", 1384 | "windows_x86_64_msvc", 1385 | ] 1386 | 1387 | [[package]] 1388 | name = "windows_aarch64_gnullvm" 1389 | version = "0.52.6" 1390 | source = "registry+https://github.com/rust-lang/crates.io-index" 1391 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1392 | 1393 | [[package]] 1394 | name = "windows_aarch64_msvc" 1395 | version = "0.52.6" 1396 | source = "registry+https://github.com/rust-lang/crates.io-index" 1397 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1398 | 1399 | [[package]] 1400 | name = "windows_i686_gnu" 1401 | version = "0.52.6" 1402 | source = "registry+https://github.com/rust-lang/crates.io-index" 1403 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1404 | 1405 | [[package]] 1406 | name = "windows_i686_gnullvm" 1407 | version = "0.52.6" 1408 | source = "registry+https://github.com/rust-lang/crates.io-index" 1409 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1410 | 1411 | [[package]] 1412 | name = "windows_i686_msvc" 1413 | version = "0.52.6" 1414 | source = "registry+https://github.com/rust-lang/crates.io-index" 1415 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1416 | 1417 | [[package]] 1418 | name = "windows_x86_64_gnu" 1419 | version = "0.52.6" 1420 | source = "registry+https://github.com/rust-lang/crates.io-index" 1421 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1422 | 1423 | [[package]] 1424 | name = "windows_x86_64_gnullvm" 1425 | version = "0.52.6" 1426 | source = "registry+https://github.com/rust-lang/crates.io-index" 1427 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1428 | 1429 | [[package]] 1430 | name = "windows_x86_64_msvc" 1431 | version = "0.52.6" 1432 | source = "registry+https://github.com/rust-lang/crates.io-index" 1433 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1434 | 1435 | [[package]] 1436 | name = "winnow" 1437 | version = "0.7.7" 1438 | source = "registry+https://github.com/rust-lang/crates.io-index" 1439 | checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" 1440 | dependencies = [ 1441 | "memchr", 1442 | ] 1443 | 1444 | [[package]] 1445 | name = "wit-bindgen-rt" 1446 | version = "0.39.0" 1447 | source = "registry+https://github.com/rust-lang/crates.io-index" 1448 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 1449 | dependencies = [ 1450 | "bitflags 2.9.0", 1451 | ] 1452 | 1453 | [[package]] 1454 | name = "wyz" 1455 | version = "0.5.1" 1456 | source = "registry+https://github.com/rust-lang/crates.io-index" 1457 | checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" 1458 | dependencies = [ 1459 | "tap", 1460 | ] 1461 | 1462 | [[package]] 1463 | name = "zerocopy" 1464 | version = "0.8.24" 1465 | source = "registry+https://github.com/rust-lang/crates.io-index" 1466 | checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" 1467 | dependencies = [ 1468 | "zerocopy-derive", 1469 | ] 1470 | 1471 | [[package]] 1472 | name = "zerocopy-derive" 1473 | version = "0.8.24" 1474 | source = "registry+https://github.com/rust-lang/crates.io-index" 1475 | checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" 1476 | dependencies = [ 1477 | "proc-macro2", 1478 | "quote", 1479 | "syn", 1480 | ] 1481 | 1482 | [[package]] 1483 | name = "zune-core" 1484 | version = "0.4.12" 1485 | source = "registry+https://github.com/rust-lang/crates.io-index" 1486 | checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" 1487 | 1488 | [[package]] 1489 | name = "zune-inflate" 1490 | version = "0.2.54" 1491 | source = "registry+https://github.com/rust-lang/crates.io-index" 1492 | checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" 1493 | dependencies = [ 1494 | "simd-adler32", 1495 | ] 1496 | 1497 | [[package]] 1498 | name = "zune-jpeg" 1499 | version = "0.4.14" 1500 | source = "registry+https://github.com/rust-lang/crates.io-index" 1501 | checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" 1502 | dependencies = [ 1503 | "zune-core", 1504 | ] 1505 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spritter" 3 | version = "1.8.0" 4 | edition = "2021" 5 | authors = ["fgardt "] 6 | description = "Spritesheet generator for factorio" 7 | repository = "https://github.com/fgardt/factorio-spritter" 8 | 9 | [profile.release] 10 | strip = true 11 | lto = "thin" 12 | 13 | [lints.rust] 14 | unsafe_code = "forbid" 15 | 16 | [lints.clippy] 17 | nursery = { level = "warn", priority = -1 } 18 | pedantic = { level = "warn", priority = -1 } 19 | unwrap_used = "warn" 20 | expect_used = "warn" 21 | module_name_repetitions = "allow" 22 | cast_possible_truncation = "allow" 23 | cast_precision_loss = "allow" 24 | cast_possible_wrap = "allow" 25 | cast_lossless = "allow" 26 | cast_sign_loss = "allow" 27 | 28 | [dependencies] 29 | clap = { version = "4.5", features = ["derive"] } 30 | env_logger = "0.10" 31 | image = { version = "0.25", features = ["png", "gif"] } 32 | log = "0.4" 33 | rayon = "1.10" 34 | strum = { version = "0.27", features = ["derive"] } 35 | thiserror = "2" 36 | natord = "1.0" 37 | oxipng = { version = "9", default-features = false, features = ["parallel"] } 38 | imagequant = "4" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Florian Gebhardt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![actions status](https://img.shields.io/github/actions/workflow/status/fgardt/factorio-spritter/rust.yml) 2 | [![release](https://img.shields.io/github/v/release/fgardt/factorio-spritter)](https://github.com/fgardt/factorio-spritter/releases) 3 | [![ko-fi](https://img.shields.io/badge/Ko--fi-Donate%20-hotpink?logo=kofi&logoColor=white)](https://ko-fi.com/fgardt) 4 | 5 | # Spritter 6 | 7 | A simple CLI tool to combine individual sprites into spritesheets for factorio. 8 | 9 | ## Usage 10 | 11 | ``` 12 | ~$ spritter help 13 | Spritesheet generator for factorio 14 | 15 | Usage: spritter 16 | 17 | Commands: 18 | spritesheet Generate spritesheets from a folder of images 19 | icon Generate a mipmap icon from a folder of images 20 | gif Generate a gif from a folder of images 21 | optimize Optimize an image or a folder of images 22 | split Split a spritesheet into individual images 23 | help Print this message or the help of the given subcommand(s) 24 | 25 | Options: 26 | -h, --help Print help 27 | -V, --version Print version 28 | ``` 29 | 30 | ### Spritesheet 31 | 32 | ``` 33 | ~$ spritter help spritesheet 34 | Generate spritesheets from a folder of images 35 | 36 | Usage: spritter spritesheet [OPTIONS] 37 | 38 | Arguments: 39 | Folder containing the individual sprites 40 | Output folder 41 | 42 | Options: 43 | -l, --lua 44 | Enable lua output generation 45 | -j, --json 46 | Enable json output generation 47 | -p, --prefix 48 | Prefix to add to the output file name [default: ] 49 | --lossy 50 | Allow lossy compression for the output images. This is using pngquant / imagequant internally 51 | -r, --recursive 52 | Recursive search for images. Each folder will be a separate spritesheet 53 | -t, --tile-resolution 54 | Resolution of the input sprites in pixels / tile [default: 64] 55 | --no-crop 56 | Set when the sprites should not be cropped 57 | -a, --crop-alpha 58 | Sets the max alpha value to consider a pixel as transparent [0-255]. 59 | Use a higher value in case your inputs have slightly transparent pixels and don't crop nicely. [default: 0] 60 | -b, --transparent-black 61 | Sets the max channel value to consider a pixel as black. 62 | All "black" pixels will be turned fully transparent. 63 | -d, --deduplicate-empty-frames 64 | Remove duplicate empty frames before building the spritesheet. 65 | This will generate a frame_sequence in the data output to restore the original frame order. 66 | Make sure to have the --lua or --json flag set to receive the data output! 67 | -s, --scale 68 | Set a scaling factor to rescale the used sprites by. 69 | Values < 1.0 will shrink the sprites. Values > 1.0 will enlarge them. [default: 1] 70 | --scale-filter 71 | The scaling filter to use when scaling sprites [default: catmull-rom] [possible values: nearest, triangle, catmull-rom, gaussian, lanczos3] 72 | --single-sheet-split-mode 73 | Automatically split each frame into multiple subframes if the frames would not fit on a single sheet. 74 | This allows you to use large sprites for graphic types that do not allow to specify multiple files for a single layer. 75 | -m, --max-sheet-size 76 | Maximum size of a single sheet in frames per axis. 77 | A value of 0 means unlimited. [default: 0] 78 | -w, --max-sheet-width 79 | Maximum width of a single sheet in frames. 80 | A value of 0 means unlimited. 81 | Use this in combination with --max-sheet-size to precisely control the size of sheets. [default: 0] 82 | --layout-mode 83 | The sheet layout mode to use. 84 | This affects how many rows and columns are used to arrange the sprites on the sheet. 85 | "square" tries to arrange the sprites in a way that the resulting sheet is as square as possible. 86 | "fill-row" maximizes the number of columns while "fill-column" maximizes the number of rows. [default: square] [possible values: square, fill-row, fill-column] 87 | ``` 88 | 89 | ### Icon 90 | 91 | ``` 92 | ~$ spritter help icon 93 | Generate a mipmap icon from a folder of images. 94 | 95 | The individual images are used as the respective mip levels and combined into a single image. 96 | 97 | Usage: spritter icon [OPTIONS] 98 | 99 | Arguments: 100 | 101 | Folder containing the individual sprites 102 | 103 | 104 | Output folder 105 | 106 | Options: 107 | -l, --lua 108 | Enable lua output generation 109 | 110 | -j, --json 111 | Enable json output generation 112 | 113 | -p, --prefix 114 | Prefix to add to the output file name 115 | 116 | [default: ] 117 | 118 | --lossy 119 | Allow lossy compression for the output images. This is using pngquant / imagequant internally 120 | ``` 121 | 122 | ### Gif 123 | ``` 124 | ~$ spritter help gif 125 | Generate a gif from a folder of images. 126 | 127 | Note: Don't use gifs for in-game graphics. This is meant for documentation / preview purposes only. 128 | 129 | Usage: spritter gif [OPTIONS] 130 | 131 | Arguments: 132 | 133 | Folder containing the individual sprites 134 | 135 | 136 | Output folder 137 | 138 | Options: 139 | -p, --prefix 140 | Prefix to add to the output file name 141 | 142 | [default: ] 143 | 144 | --lossy 145 | Allow lossy compression for the output images. This is using pngquant / imagequant internally 146 | 147 | -s, --animation-speed 148 | Animation speed to use for the gif. 149 | This is identical to in-game speed. 1.0 means 60 frames per second. 150 | Note: GIFs frame delay is in steps of 10ms, so the actual speed might be slightly different. 151 | 152 | [default: 1.0] 153 | 154 | -a, --alpha-threshold 155 | Alpha threshold to consider a pixel as transparent [0-255]. 156 | Since GIFS only support 1-bit transparency, this is used to determine which pixels are transparent. 157 | 158 | [default: 0] 159 | ``` 160 | 161 | ### Optimize 162 | ``` 163 | ~$ spritter help optimize 164 | Optimize an image or a folder of images. 165 | 166 | This is using oxipng (and optionally pngquant / imagequant when lossy is enabled). Note: the original images will be replaced with the optimized versions. 167 | 168 | Usage: spritter optimize [OPTIONS] 169 | 170 | Arguments: 171 | 172 | 173 | Options: 174 | -r, --recursive 175 | Recursively search for images in the target folder 176 | 177 | -g, --group 178 | Treat images as a group and optimize them together instead of individually. 179 | This only has an effect with lossy compression. 180 | 181 | --lossy 182 | Allow lossy compression 183 | ``` 184 | 185 | ### Split 186 | 187 | ``` 188 | ~$ spritter help split 189 | Split a spritesheet into individual images 190 | 191 | Usage: spritter split 192 | 193 | Arguments: 194 | The spritesheet to split into individual frames 195 | Number of frames horizontally 196 | Number of frames vertically 197 | Output folder 198 | ``` 199 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | mod gif; 2 | mod icon; 3 | mod optimize; 4 | mod split; 5 | mod spritesheet; 6 | 7 | pub use gif::*; 8 | pub use icon::*; 9 | pub use optimize::*; 10 | pub use split::*; 11 | pub use spritesheet::*; 12 | 13 | use clap::{Args, Subcommand}; 14 | use std::path::{Path, PathBuf}; 15 | 16 | #[derive(Subcommand, Debug)] 17 | pub enum GenerationCommand { 18 | /// Generate spritesheets from a folder of images. 19 | Spritesheet { 20 | // args 21 | #[clap(flatten)] 22 | args: SpritesheetArgs, 23 | }, 24 | 25 | /// Generate a mipmap icon from a folder of images. 26 | /// 27 | /// The individual images are used as the respective mip levels and combined into a single image. 28 | Icon { 29 | // args 30 | #[clap(flatten)] 31 | args: IconArgs, 32 | }, 33 | 34 | /// Generate a gif from a folder of images. 35 | /// 36 | /// Note: Don't use gifs for in-game graphics. This is meant for documentation / preview purposes only. 37 | Gif { 38 | // args 39 | #[clap(flatten)] 40 | args: GifArgs, 41 | }, 42 | 43 | /// Optimize an image or a folder of images. 44 | /// 45 | /// This is using oxipng (and optionally pngquant / imagequant when lossy is enabled). 46 | /// Note: the original images will be replaced with the optimized versions. 47 | Optimize { 48 | // args 49 | #[clap(flatten)] 50 | args: OptimizeArgs, 51 | }, 52 | 53 | /// Split a spritesheet into individual images. 54 | Split { 55 | // args 56 | #[clap(flatten)] 57 | args: SplitArgs, 58 | }, 59 | } 60 | 61 | #[derive(Debug, thiserror::Error)] 62 | pub enum CommandError { 63 | #[error("io error: {0}")] 64 | IoError(#[from] std::io::Error), 65 | 66 | #[error("image error: {0}")] 67 | ImageError(#[from] image::ImageError), 68 | 69 | #[error("{0}")] 70 | ImgUtilError(#[from] crate::image_util::ImgUtilError), 71 | 72 | #[error("output path is not a directory")] 73 | OutputPathNotDir, 74 | 75 | #[error("{0}")] 76 | SpriteSheetError(#[from] SpriteSheetError), 77 | 78 | #[error("{0}")] 79 | IconError(#[from] IconError), 80 | } 81 | 82 | #[derive(Args, Debug)] 83 | pub struct SharedArgs { 84 | /// Folder containing the individual sprites. 85 | pub source: PathBuf, 86 | 87 | /// Output folder. 88 | pub output: PathBuf, 89 | 90 | /// Enable lua output generation. 91 | #[clap(short, long, action)] 92 | lua: bool, 93 | 94 | /// Enable json output generation. 95 | #[clap(short, long, action)] 96 | json: bool, 97 | 98 | /// Prefix to add to the output file name. 99 | #[clap(short, long, default_value_t = String::new())] 100 | prefix: String, 101 | 102 | /// Allow lossy compression for the output images. 103 | /// This is using pngquant / imagequant internally. 104 | #[clap(long, action)] 105 | lossy: bool, 106 | } 107 | 108 | fn output_name( 109 | source: impl AsRef, 110 | output_dir: impl AsRef, 111 | id: Option, 112 | prefix: &str, 113 | extension: &str, 114 | ) -> Result { 115 | #[allow(clippy::unwrap_used)] 116 | let name = source 117 | .as_ref() 118 | .canonicalize()? 119 | .components() 120 | .next_back() 121 | .unwrap() 122 | .as_os_str() 123 | .to_string_lossy() 124 | .to_string(); 125 | 126 | let pre_suff_name = id.map_or_else( 127 | || format!("{prefix}{name}"), 128 | |id| format!("{prefix}{name}-{id}"), 129 | ); 130 | 131 | let mut out = output_dir.as_ref().join(pre_suff_name); 132 | out.set_extension(extension); 133 | 134 | Ok(out) 135 | } 136 | -------------------------------------------------------------------------------- /src/commands/gif.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use clap::Args; 4 | 5 | use super::{output_name, CommandError}; 6 | use crate::image_util; 7 | 8 | #[derive(Args, Debug)] 9 | pub struct GifArgs { 10 | // shared args 11 | #[clap(flatten)] 12 | shared: super::SharedArgs, 13 | 14 | /// Animation speed to use for the gif. 15 | /// This is identical to in-game speed. 1.0 means 60 frames per second. 16 | /// Note: GIFs frame delay is in steps of 10ms, so the actual speed might be slightly different. 17 | #[clap(short = 's', long, default_value = "1.0", verbatim_doc_comment)] 18 | pub animation_speed: f64, 19 | 20 | /// Alpha threshold to consider a pixel as transparent [0-255]. 21 | /// Since GIFS only support 1-bit transparency, this is used to determine which pixels are transparent. 22 | #[clap(short, long, default_value = "0", verbatim_doc_comment)] 23 | pub alpha_threshold: u8, 24 | } 25 | 26 | impl std::ops::Deref for GifArgs { 27 | type Target = super::SharedArgs; 28 | 29 | fn deref(&self) -> &Self::Target { 30 | &self.shared 31 | } 32 | } 33 | 34 | pub fn generate_gif(args: &GifArgs) -> Result<(), CommandError> { 35 | use image::{codecs::gif, Delay, Frame}; 36 | 37 | if args.lua { 38 | warn!("lua output is not supported for gifs"); 39 | } 40 | 41 | if args.animation_speed <= 0.0 { 42 | warn!("animation speed must be greater than 0"); 43 | return Ok(()); 44 | } 45 | 46 | let mut images = image_util::load_from_path(&args.source)?; 47 | 48 | if images.is_empty() { 49 | warn!("no source images found"); 50 | return Ok(()); 51 | } 52 | 53 | for img in &mut images { 54 | for pxl in img.pixels_mut() { 55 | if pxl[3] <= 10 { 56 | pxl[0] = 0; 57 | pxl[1] = 0; 58 | pxl[2] = 0; 59 | pxl[3] = 0; 60 | } 61 | } 62 | } 63 | 64 | let mut file = fs::File::create(output_name( 65 | &args.source, 66 | &args.output, 67 | None, 68 | &args.prefix, 69 | ".gif", 70 | )?)?; 71 | 72 | let mut encoder = gif::GifEncoder::new(&mut file); 73 | encoder.set_repeat(gif::Repeat::Infinite)?; 74 | 75 | encoder.try_encode_frames(images.iter().map(|img| { 76 | Ok(Frame::from_parts( 77 | img.clone(), 78 | 0, 79 | 0, 80 | Delay::from_numer_denom_ms(100_000, (6000.0 * args.animation_speed).round() as u32), 81 | )) 82 | }))?; 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/icon.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use clap::Args; 4 | use image::ImageBuffer; 5 | 6 | use super::{output_name, CommandError}; 7 | use crate::{ 8 | image_util::{self, ImageBufferExt as _}, 9 | lua::LuaOutput, 10 | }; 11 | 12 | #[derive(Debug, thiserror::Error)] 13 | pub enum IconError { 14 | #[error("source image is not square")] 15 | ImageNotSquare, 16 | 17 | #[error("unable to generate {0} mipmap levels, max possible for this icon is {1}")] 18 | TooManyImages(usize, usize), 19 | 20 | #[error("unable to divide image size by 2 for mipmap level {0}")] 21 | OddImageSizeForMipLevel(usize), 22 | 23 | #[error("source image has wrong size, {0} != {1}")] 24 | WrongImageSize(u32, u32), 25 | } 26 | 27 | #[derive(Args, Debug)] 28 | pub struct IconArgs { 29 | // shared args 30 | #[clap(flatten)] 31 | shared: super::SharedArgs, 32 | } 33 | 34 | impl std::ops::Deref for IconArgs { 35 | type Target = super::SharedArgs; 36 | 37 | fn deref(&self) -> &Self::Target { 38 | &self.shared 39 | } 40 | } 41 | 42 | pub fn generate_mipmap_icon(args: &IconArgs) -> Result<(), CommandError> { 43 | fs::create_dir_all(&args.output)?; 44 | if !args.output.is_dir() { 45 | return Err(CommandError::OutputPathNotDir); 46 | } 47 | 48 | let mut images = image_util::load_from_path(&args.source)?; 49 | if images.is_empty() { 50 | warn!("no source images found"); 51 | return Ok(()); 52 | } 53 | 54 | images.sort_by_key(ImageBuffer::width); 55 | images.reverse(); 56 | 57 | #[allow(clippy::unwrap_used)] 58 | let (base_width, base_height) = images.first().unwrap().dimensions(); 59 | if base_width != base_height { 60 | Err(IconError::ImageNotSquare)?; 61 | } 62 | 63 | #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 64 | let max_mipmap_levels = (f64::from(base_width)).log2().floor() as usize; 65 | 66 | if images.len() > max_mipmap_levels { 67 | Err(IconError::TooManyImages(images.len(), max_mipmap_levels))?; 68 | } 69 | 70 | let mut res = ImageBuffer::new(base_width * 2, base_height); 71 | 72 | let mut next_width = base_width; 73 | let mut next_x = 0; 74 | 75 | for (idx, sprite) in images.iter().enumerate() { 76 | if next_width.rem_euclid(2) != 0 { 77 | Err(IconError::OddImageSizeForMipLevel(idx))?; 78 | } 79 | 80 | if sprite.width() != sprite.height() { 81 | Err(IconError::ImageNotSquare)?; 82 | } 83 | 84 | if sprite.width() != next_width { 85 | Err(IconError::WrongImageSize(sprite.width(), next_width))?; 86 | } 87 | 88 | image::imageops::replace(&mut res, sprite, i64::from(next_x), 0); 89 | 90 | next_x += next_width; 91 | next_width /= 2; 92 | } 93 | 94 | image::imageops::crop_imm(&res, 0, 0, next_x, res.height()) 95 | .to_image() 96 | .save_optimized_png( 97 | output_name(&args.source, &args.output, None, &args.prefix, "png")?, 98 | args.lossy, 99 | )?; 100 | 101 | if args.lua || args.json { 102 | let data = LuaOutput::new() 103 | .set("icon_size", base_width) 104 | .set("icon_mipmaps", images.len()); 105 | 106 | if args.lua { 107 | let out = output_name(&args.source, &args.output, None, &args.prefix, "lua")?; 108 | data.save(&out)?; 109 | } 110 | if args.json { 111 | let out = output_name(&args.source, &args.output, None, &args.prefix, "json")?; 112 | data.save_as_json(out)?; 113 | } 114 | } 115 | 116 | Ok(()) 117 | } 118 | -------------------------------------------------------------------------------- /src/commands/optimize.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use clap::Args; 7 | 8 | use super::CommandError; 9 | use crate::image_util::{self, ImageBufferExt as _, ImgUtilError}; 10 | 11 | #[derive(Args, Debug)] 12 | pub struct OptimizeArgs { 13 | pub target: PathBuf, 14 | 15 | /// Recursively search for images in the target folder. 16 | #[clap(short, long, action)] 17 | pub recursive: bool, 18 | 19 | /// Treat images as a group and optimize them together instead of individually. 20 | /// This only has an effect with lossy compression. 21 | #[clap(short, long, action, verbatim_doc_comment)] 22 | pub group: bool, 23 | 24 | /// Allow lossy compression. 25 | #[clap(long, action)] 26 | pub lossy: bool, 27 | } 28 | 29 | pub fn optimize(args: &OptimizeArgs) -> Result<(), CommandError> { 30 | let mut paths = Vec::new(); 31 | 32 | if args.target.is_dir() { 33 | paths.extend(pngs_in_folder(&args.target)?); 34 | 35 | if args.recursive { 36 | let folders = recursive_folders(&args.target)?; 37 | 38 | for folder in &folders { 39 | paths.extend(pngs_in_folder(folder)?); 40 | } 41 | 42 | info!( 43 | "found {} images after searching through {} folders", 44 | paths.len(), 45 | folders.len() 46 | ); 47 | } 48 | } else { 49 | if args.recursive { 50 | warn!("target is not a directory, recursive search disabled"); 51 | } 52 | 53 | if args.target.extension().is_some_and(|ext| ext == "png") { 54 | paths.push(args.target.clone()); 55 | } 56 | } 57 | 58 | if paths.is_empty() { 59 | warn!("no source images found"); 60 | return Ok(()); 61 | } 62 | 63 | if args.group { 64 | if args.lossy { 65 | return optimize_lossy_grouped(&paths); 66 | } 67 | 68 | warn!("group optimization only has an effect with lossy compression, ignoring group flag"); 69 | } 70 | 71 | optimize_seq_runner(&paths, |path| optimize_single(path, args.lossy)); 72 | 73 | Ok(()) 74 | } 75 | 76 | fn optimize_lossy_grouped(paths: &[PathBuf]) -> Result<(), CommandError> { 77 | let quant = image_util::quantization_attributes()?; 78 | let mut histo = imagequant::Histogram::new(&quant); 79 | 80 | info!("generating histogram of all images"); 81 | let known_good_paths = paths 82 | .iter() 83 | .filter(|path| match image_util::load_image_from_file(path) { 84 | Ok(img) => { 85 | if let Err(err) = histo.add_colors(&img.get_histogram(), 0.0) { 86 | warn!("{}: {err}", path.display()); 87 | false 88 | } else { 89 | true 90 | } 91 | } 92 | Err(err) => { 93 | warn!("{}: {err}", path.display()); 94 | false 95 | } 96 | }) 97 | .cloned() 98 | .collect::>(); 99 | 100 | if known_good_paths.is_empty() { 101 | warn!("no source images found"); 102 | return Ok(()); 103 | } 104 | 105 | let mut qres = histo.quantize(&quant).map_err(ImgUtilError::from)?; 106 | qres.set_dithering_level(1.0).map_err(ImgUtilError::from)?; 107 | let palette = image_util::convert_palette(qres.palette()); 108 | 109 | info!("optimizing images"); 110 | 111 | optimize_seq_runner(&known_good_paths, |path| { 112 | optimize_single_quantized(path, &quant, &mut qres, &palette) 113 | }); 114 | 115 | Ok(()) 116 | } 117 | 118 | fn optimize_seq_runner(paths: &[PathBuf], mut step: S) 119 | where 120 | S: FnMut(&PathBuf) -> Result<(u64, u64), ImgUtilError>, 121 | { 122 | let mut total_in = 0; 123 | let mut total_out = 0; 124 | 125 | for path in paths { 126 | match step(path) { 127 | Ok((b_in, b_out)) => { 128 | total_in += b_in; 129 | total_out += b_out; 130 | } 131 | Err(err) => { 132 | error!("{}: {err}", path.display()); 133 | } 134 | } 135 | } 136 | 137 | let reduced_by = total_in - total_out; 138 | let percent = ((total_out as f64 / total_in as f64) - 1.0) * 100.0; 139 | info!( 140 | "total: {percent:.2}%, saved {}", 141 | human_readable_bytes(reduced_by) 142 | ); 143 | } 144 | 145 | fn optimize_single(path: &PathBuf, lossy: bool) -> Result<(u64, u64), ImgUtilError> { 146 | let orig = std::fs::read(path)?; 147 | let orig_size = orig.len() as u64; 148 | let res_size = image_util::load_image_from_file(path)?.save_optimized_png(path, lossy)?; 149 | 150 | optimize_common_res(path, &orig, orig_size, res_size) 151 | } 152 | 153 | fn optimize_single_quantized( 154 | path: &PathBuf, 155 | quant: &imagequant::Attributes, 156 | qres: &mut imagequant::QuantizationResult, 157 | palette: &[[u8; 4]], 158 | ) -> Result<(u64, u64), ImgUtilError> { 159 | let orig = std::fs::read(path)?; 160 | let orig_size = orig.len() as u64; 161 | 162 | let img = image_util::load_image_from_file(path)?; 163 | let (width, height) = img.dimensions(); 164 | let w_usize = width as usize; 165 | let h_usize = height as usize; 166 | let mut img = quant.new_image(img.to_quant_img(), w_usize, h_usize, 0.0)?; 167 | 168 | let mut pxls = Vec::with_capacity(w_usize * h_usize); 169 | qres.remap_into_vec(&mut img, &mut pxls)?; 170 | 171 | let res_size = image_util::optimize_png( 172 | &image_util::image_buf_from_palette(width, height, palette, &pxls), 173 | width, 174 | height, 175 | path, 176 | )?; 177 | 178 | optimize_common_res(path, &orig, orig_size, res_size) 179 | } 180 | 181 | fn optimize_common_res( 182 | path: &PathBuf, 183 | orig: &[u8], 184 | orig_size: u64, 185 | res_size: u64, 186 | ) -> Result<(u64, u64), ImgUtilError> { 187 | if res_size >= orig_size { 188 | info!("{}: could not optimize further", path.display()); 189 | std::fs::write(path, orig)?; 190 | Ok((orig_size, orig_size)) 191 | } else { 192 | let reduced_by = orig_size - res_size; 193 | let percent = ((res_size as f64 / orig_size as f64) - 1.0) * 100.0; 194 | 195 | info!( 196 | "{}: {percent:.2}% smaller, saved {}", 197 | path.display(), 198 | human_readable_bytes(reduced_by) 199 | ); 200 | 201 | Ok((orig_size, res_size)) 202 | } 203 | } 204 | 205 | fn recursive_folders(path: impl AsRef) -> std::io::Result> { 206 | let mut folders = Vec::new(); 207 | 208 | for entry in fs::read_dir(path)? { 209 | let entry = entry?; 210 | let path = entry.path(); 211 | 212 | if path.is_dir() { 213 | folders.push(path); 214 | } 215 | } 216 | 217 | let mut descent = Vec::new(); 218 | for folder in &folders { 219 | descent.extend(recursive_folders(folder)?); 220 | } 221 | 222 | folders.extend(descent); 223 | Ok(folders.into_boxed_slice()) 224 | } 225 | 226 | fn pngs_in_folder(path: impl AsRef) -> std::io::Result> { 227 | let mut pngs = Vec::new(); 228 | 229 | for entry in fs::read_dir(path)? { 230 | let entry = entry?; 231 | let path = entry.path(); 232 | 233 | if path.is_file() && path.extension().is_some_and(|ext| ext == "png") { 234 | pngs.push(path); 235 | } 236 | } 237 | 238 | Ok(pngs.into_boxed_slice()) 239 | } 240 | 241 | fn human_readable_bytes(bytes: u64) -> String { 242 | static UNITS: [&str; 6] = ["B", "kB", "MB", "GB", "TB", "PB"]; // wtf are you doing if this saves you petabytes -.- 243 | 244 | if bytes < 1000 { 245 | return format!("{bytes}{}", UNITS[0]); 246 | } 247 | 248 | let mut size = bytes as f64; 249 | let mut unit = 0; 250 | 251 | while size >= 1000.0 && unit < UNITS.len() - 1 { 252 | size /= 1000.0; 253 | unit += 1; 254 | } 255 | 256 | format!("{:.2}{}", size, UNITS[unit]) 257 | } 258 | -------------------------------------------------------------------------------- /src/commands/split.rs: -------------------------------------------------------------------------------- 1 | use std::{num::NonZeroU32, path::PathBuf}; 2 | 3 | use clap::Args; 4 | use image::GenericImageView as _; 5 | 6 | use super::CommandError; 7 | 8 | #[derive(Args, Debug)] 9 | pub struct SplitArgs { 10 | /// The spritesheet to split into individual frames. 11 | pub source: PathBuf, 12 | 13 | /// Number of frames horizontally. 14 | pub width: NonZeroU32, 15 | /// Number of frames vertically. 16 | pub height: NonZeroU32, 17 | 18 | /// Output folder. 19 | pub output: PathBuf, 20 | } 21 | 22 | pub fn split(args: &SplitArgs) -> Result<(), CommandError> { 23 | let source = crate::image_util::load_image_from_file(&args.source)?; 24 | 25 | let width = args.width.get(); 26 | let height = args.height.get(); 27 | let (px_w, px_h) = source.dimensions(); 28 | let frame_w = px_w / width; 29 | let frame_h = px_h / height; 30 | 31 | std::fs::create_dir_all(&args.output)?; 32 | 33 | for y in 0..height { 34 | let pos_y = y * frame_h; 35 | 36 | for x in 0..width { 37 | let pos_x = x * frame_w; 38 | 39 | source 40 | .view(pos_x, pos_y, frame_w, frame_h) 41 | .to_image() 42 | .save(args.output.join(format!("./{}.png", x + width * y)))?; 43 | } 44 | } 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/spritesheet.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use clap::{builder::PossibleValue, Args, ValueEnum}; 7 | use image::{ 8 | imageops::{self, FilterType}, 9 | RgbaImage, 10 | }; 11 | use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _}; 12 | use strum::{EnumIter, VariantArray}; 13 | 14 | use super::{CommandError, SharedArgs}; 15 | use crate::{commands::output_name, image_util, lua::LuaOutput}; 16 | 17 | #[allow(clippy::struct_excessive_bools)] 18 | #[derive(Args, Debug)] 19 | pub struct SpritesheetArgs { 20 | // shared args 21 | #[clap(flatten)] 22 | shared: SharedArgs, 23 | 24 | /// Recursive search for images. Each folder will be a separate spritesheet 25 | #[clap(short, long, action)] 26 | pub recursive: bool, 27 | 28 | /// Resolution of the input sprites in pixels / tile 29 | #[clap(short, long, default_value_t = 64)] 30 | pub tile_resolution: usize, 31 | 32 | /// Set when the sprites should not be cropped 33 | #[clap(long, action)] 34 | pub no_crop: bool, 35 | 36 | /// Sets the max alpha value to consider a pixel as transparent [0-255]. 37 | /// Use a higher value in case your inputs have slightly transparent pixels and don't crop nicely. 38 | #[clap(short = 'a', long, default_value_t = 0, verbatim_doc_comment)] 39 | pub crop_alpha: u8, 40 | 41 | /// Sets the max channel value to consider a pixel as black. 42 | /// All "black" pixels will be turned fully transparent. 43 | #[clap(short = 'b', long, default_value = None, verbatim_doc_comment)] 44 | pub transparent_black: Option, 45 | 46 | /// Remove duplicate empty frames before building the spritesheet. 47 | /// This will generate a `frame_sequence` in the data output to restore the original frame order. 48 | /// Make sure to have the --lua or --json flag set to receive the data output! 49 | #[clap(short, long, action, verbatim_doc_comment)] 50 | pub deduplicate_empty_frames: bool, 51 | 52 | /// Set a scaling factor to rescale the used sprites by. 53 | /// Values < 1.0 will shrink the sprites. Values > 1.0 will enlarge them. 54 | #[clap(short, long, default_value_t = 1.0, verbatim_doc_comment)] 55 | pub scale: f64, 56 | 57 | /// The scaling filter to use when scaling sprites 58 | #[clap(long, default_value_t = ScaleFilter::CatmullRom, verbatim_doc_comment)] 59 | pub scale_filter: ScaleFilter, 60 | 61 | /// Automatically split each frame into multiple subframes if the frames would not fit on a single sheet. 62 | /// This allows you to use large sprites for graphic types that do not allow to specify multiple files for a single layer. 63 | #[clap(long, action, verbatim_doc_comment)] 64 | pub single_sheet_split_mode: bool, 65 | 66 | /// Maximum size of a single sheet in frames per axis. 67 | /// A value of 0 means unlimited. 68 | #[clap(short, long, default_value_t = 0, verbatim_doc_comment)] 69 | pub max_sheet_size: u32, 70 | 71 | /// Maximum width of a single sheet in frames. 72 | /// A value of 0 means unlimited. 73 | /// Use this in combination with --max-sheet-size to precisely control the size of sheets. 74 | #[clap(short = 'w', long, default_value_t = 0, verbatim_doc_comment)] 75 | pub max_sheet_width: u32, 76 | 77 | /// The sheet layout mode to use. 78 | /// This affects how many rows and columns are used to arrange the sprites on the sheet. 79 | /// "square" tries to arrange the sprites in a way that the resulting sheet is as square as possible. 80 | /// "fill-row" maximizes the number of columns while "fill-column" maximizes the number of rows. 81 | #[clap(long, default_value_t = SheetLayoutMode::Square, verbatim_doc_comment)] 82 | pub layout_mode: SheetLayoutMode, 83 | } 84 | 85 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter, VariantArray)] 86 | pub enum ScaleFilter { 87 | Nearest, 88 | Triangle, 89 | CatmullRom, 90 | Gaussian, 91 | Lanczos3, 92 | } 93 | 94 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter, VariantArray)] 95 | pub enum SheetLayoutMode { 96 | Square, 97 | FillRow, 98 | FillColumn, 99 | } 100 | 101 | #[derive(Debug, thiserror::Error)] 102 | pub enum SpriteSheetError { 103 | #[error("all source images must be the same size")] 104 | ImagesNotSameSize, 105 | } 106 | 107 | impl std::fmt::Display for ScaleFilter { 108 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 109 | match self { 110 | Self::Nearest => write!(f, "nearest"), 111 | Self::Triangle => write!(f, "triangle"), 112 | Self::CatmullRom => write!(f, "catmull-rom"), 113 | Self::Gaussian => write!(f, "gaussian"), 114 | Self::Lanczos3 => write!(f, "lanczos3"), 115 | } 116 | } 117 | } 118 | 119 | impl From for FilterType { 120 | fn from(value: ScaleFilter) -> Self { 121 | match value { 122 | ScaleFilter::Nearest => Self::Nearest, 123 | ScaleFilter::Triangle => Self::Triangle, 124 | ScaleFilter::CatmullRom => Self::CatmullRom, 125 | ScaleFilter::Gaussian => Self::Gaussian, 126 | ScaleFilter::Lanczos3 => Self::Lanczos3, 127 | } 128 | } 129 | } 130 | 131 | impl ValueEnum for ScaleFilter { 132 | fn value_variants<'a>() -> &'a [Self] { 133 | Self::VARIANTS 134 | } 135 | 136 | fn to_possible_value(&self) -> Option { 137 | Some(PossibleValue::new(match self { 138 | Self::Nearest => "nearest", 139 | Self::Triangle => "triangle", 140 | Self::CatmullRom => "catmull-rom", 141 | Self::Gaussian => "gaussian", 142 | Self::Lanczos3 => "lanczos3", 143 | })) 144 | } 145 | } 146 | 147 | impl std::fmt::Display for SheetLayoutMode { 148 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 149 | match self { 150 | Self::Square => write!(f, "square"), 151 | Self::FillRow => write!(f, "fill-row"), 152 | Self::FillColumn => write!(f, "fill-column"), 153 | } 154 | } 155 | } 156 | 157 | impl ValueEnum for SheetLayoutMode { 158 | fn value_variants<'a>() -> &'a [Self] { 159 | Self::VARIANTS 160 | } 161 | 162 | fn to_possible_value(&self) -> Option { 163 | Some(PossibleValue::new(match self { 164 | Self::Square => "square", 165 | Self::FillRow => "fill-row", 166 | Self::FillColumn => "fill-column", 167 | })) 168 | } 169 | } 170 | 171 | impl std::ops::Deref for SpritesheetArgs { 172 | type Target = SharedArgs; 173 | 174 | fn deref(&self) -> &Self::Target { 175 | &self.shared 176 | } 177 | } 178 | 179 | impl SpritesheetArgs { 180 | pub fn execute(&self) -> Result<(), CommandError> { 181 | fs::create_dir_all(&self.output)?; 182 | 183 | if !self.output.is_dir() { 184 | return Err(CommandError::OutputPathNotDir); 185 | } 186 | 187 | let sources = if self.recursive { 188 | fs::read_dir(&self.source)? 189 | .filter_map(|entry| { 190 | let path = entry.ok()?.path(); 191 | 192 | if path.is_dir() { 193 | Some(path) 194 | } else { 195 | None 196 | } 197 | }) 198 | .collect::>() 199 | } else { 200 | vec![self.source.clone()] 201 | }; 202 | 203 | if sources.is_empty() { 204 | warn!("no source directories found"); 205 | return Ok(()); 206 | } 207 | 208 | let _ = sources 209 | .par_iter() 210 | .filter_map(|source| match generate_spritesheet(self, source) { 211 | Ok(res_name) => { 212 | if res_name.is_empty() { 213 | None 214 | } else { 215 | Some(res_name) 216 | } 217 | } 218 | Err(err) => { 219 | error!("{}: {err}", source.display()); 220 | None 221 | } 222 | }) 223 | .collect::>(); 224 | 225 | Ok(()) 226 | } 227 | 228 | fn tile_res(&self) -> usize { 229 | (self.tile_resolution as f64 * self.scale).round() as usize 230 | } 231 | } 232 | 233 | /// Maximum side length of a single graphic file to load in Factorio 234 | static MAX_SIZE: u32 = 8192; 235 | 236 | #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] 237 | fn generate_spritesheet( 238 | args: &SpritesheetArgs, 239 | path: impl AsRef, 240 | ) -> Result { 241 | let source = path.as_ref(); 242 | let mut images = image_util::load_from_path(source)?; 243 | 244 | if images.is_empty() { 245 | warn!("{}: no source images found", source.display()); 246 | return Ok(String::new()); 247 | } 248 | 249 | // scale images 250 | if (args.scale - 1.0).abs() > f64::EPSILON { 251 | for image in &mut images { 252 | let (width, height) = image.dimensions(); 253 | let width = (f64::from(width) * args.scale).round() as u32; 254 | let height = (f64::from(height) * args.scale).round() as u32; 255 | 256 | *image = imageops::resize(image, width, height, args.scale_filter.into()); 257 | } 258 | } 259 | 260 | if let Some(black_limit) = args.transparent_black { 261 | image_util::transparent_black(&mut images, black_limit); 262 | } 263 | 264 | let (shift_x, shift_y) = if args.no_crop { 265 | (0.0, 0.0) 266 | } else { 267 | image_util::crop_images(&mut images, args.crop_alpha)? 268 | }; 269 | 270 | // dedup empty frames 271 | let frame_sequence = if args.deduplicate_empty_frames { 272 | let (dedup_imgs, sequence) = image_util::dedup_empty_frames(images); 273 | images = dedup_imgs; 274 | Some(sequence) 275 | } else { 276 | None 277 | }; 278 | 279 | #[allow(clippy::unwrap_used)] 280 | let (sprite_width, sprite_height) = images.first().unwrap().dimensions(); 281 | let sprite_count = images.len() as u32; 282 | 283 | let (max_cols_per_sheet, max_rows_per_sheet) = { 284 | let technical_max_cols = MAX_SIZE / sprite_width; 285 | let technical_max_rows = MAX_SIZE / sprite_height; 286 | 287 | match (args.max_sheet_size, args.max_sheet_width) { 288 | (0, 0) => (technical_max_cols, technical_max_rows), 289 | (max_sheet_size, 0) if max_sheet_size > 0 => ( 290 | max_sheet_size.min(technical_max_cols), 291 | max_sheet_size.min(technical_max_rows), 292 | ), 293 | (0, max_sheet_width) if max_sheet_width > 0 => { 294 | (max_sheet_width.min(technical_max_cols), technical_max_rows) 295 | } 296 | (max_sheet_size, max_sheet_width) => ( 297 | max_sheet_width.min(technical_max_cols), 298 | max_sheet_size.min(technical_max_rows), 299 | ), 300 | } 301 | }; 302 | 303 | let max_per_sheet = max_rows_per_sheet * max_cols_per_sheet; 304 | 305 | let sheet_count = images.len() / max_per_sheet as usize 306 | + usize::from(images.len().rem_euclid(max_per_sheet as usize) > 0); 307 | 308 | #[allow(clippy::unwrap_used)] 309 | let name = source 310 | .canonicalize()? 311 | .components() 312 | .next_back() 313 | .unwrap() 314 | .as_os_str() 315 | .to_string_lossy() 316 | .to_string(); 317 | 318 | if args.single_sheet_split_mode && sheet_count > 1 { 319 | debug!("sprites don't fit on a single sheet, splitting into multiple layers"); 320 | let layers = 321 | generate_subframe_sheets(args, &images, sprite_width, sprite_height, shift_x, shift_y); 322 | let mut lua_layers = Vec::with_capacity(layers.len()); 323 | let mut sheets = Vec::with_capacity(layers.len()); 324 | 325 | for (idx, layer) in layers.iter().enumerate() { 326 | let (sheet, (width, height), (shift_x, shift_y), (cols, rows)) = layer; 327 | let out = output_name(source, &args.output, Some(idx), &args.prefix, "png")?; 328 | 329 | let mut data = LuaOutput::new() 330 | .set("width", *width) 331 | .set("height", *height) 332 | .set("shift", (*shift_x, *shift_y, args.tile_res())) 333 | .set("scale", 32.0 / args.tile_res() as f64) 334 | .set("sprite_count", sprite_count) 335 | .set("line_length", *cols) 336 | .set("lines_per_file", *rows); 337 | 338 | if let Some(sequence) = &frame_sequence { 339 | data = data.set("frame_sequence", sequence.as_slice()); 340 | } 341 | 342 | lua_layers.push(data); 343 | 344 | sheets.push((sheet.clone(), out)); 345 | } 346 | 347 | image_util::save_sheets(&sheets, args.lossy, true)?; 348 | 349 | if args.lua { 350 | LuaOutput::new() 351 | .set("single_sheet_split_layers", lua_layers.as_slice()) 352 | .save(output_name( 353 | source, 354 | &args.output, 355 | None, 356 | &args.prefix, 357 | "lua", 358 | )?)?; 359 | } 360 | 361 | info!( 362 | "completed {}{name}, split into {} layers", 363 | args.prefix, 364 | layers.len() 365 | ); 366 | return Ok(name); 367 | } 368 | 369 | // unnecessarily overengineered PoS to calculate special sheet sizes if only 1 sheet is needed 370 | let (sheet_width, sheet_height, cols_per_sheet, rows_per_sheet, max_per_sheet) = 371 | if max_per_sheet <= sprite_count { 372 | debug!("using maximized sheet: {max_cols_per_sheet}x{max_rows_per_sheet}"); 373 | 374 | ( 375 | sprite_width * max_cols_per_sheet, 376 | sprite_height * max_rows_per_sheet, 377 | max_cols_per_sheet, 378 | max_rows_per_sheet, 379 | max_per_sheet, 380 | ) 381 | } else { 382 | trace!("calculating custom sheet size"); 383 | let mut cols = 1; 384 | let mut rows = 1; 385 | 386 | match args.layout_mode { 387 | SheetLayoutMode::Square => { 388 | while cols * rows < sprite_count { 389 | if (cols < max_cols_per_sheet) 390 | && (cols * sprite_width <= rows * sprite_height) 391 | { 392 | cols += 1; 393 | trace!("cols++ | {cols}x{rows}"); 394 | } else { 395 | rows += 1; 396 | trace!("rows++ | {cols}x{rows}"); 397 | } 398 | } 399 | } 400 | SheetLayoutMode::FillRow => { 401 | while cols * rows < sprite_count { 402 | if cols < max_cols_per_sheet { 403 | cols += 1; 404 | trace!("cols++ | {cols}x{rows}"); 405 | } else { 406 | rows += 1; 407 | trace!("rows++ | {cols}x{rows}"); 408 | } 409 | } 410 | } 411 | SheetLayoutMode::FillColumn => { 412 | while cols * rows < sprite_count { 413 | if rows < max_rows_per_sheet { 414 | rows += 1; 415 | trace!("rows++ | {cols}x{rows}"); 416 | } else { 417 | cols += 1; 418 | trace!("cols++ | {cols}x{rows}"); 419 | } 420 | } 421 | } 422 | } 423 | 424 | let empty = cols * rows - sprite_count; 425 | if empty / cols > 0 { 426 | rows -= empty / cols; 427 | trace!("rows-- | {cols}x{rows}"); 428 | } 429 | 430 | debug!("singular custom sheet: {cols}x{rows}"); 431 | 432 | ( 433 | sprite_width * cols, 434 | sprite_height * rows, 435 | cols, 436 | rows, 437 | cols * rows, 438 | ) 439 | }; 440 | 441 | debug!("sheet size: {sheet_width}x{sheet_height}"); 442 | 443 | let mut sheets: Vec<(RgbaImage, PathBuf)> = Vec::with_capacity(sheet_count); 444 | 445 | if sheet_count == 1 { 446 | sheets.push(( 447 | RgbaImage::new(sheet_width, sheet_height), 448 | output_name(source, &args.output, None, &args.prefix, "png")?, 449 | )); 450 | } else { 451 | for idx in 0..(sheet_count - 1) { 452 | sheets.push(( 453 | RgbaImage::new(sheet_width, sheet_height), 454 | output_name(source, &args.output, Some(idx), &args.prefix, "png")?, 455 | )); 456 | } 457 | 458 | // last sheet can be smaller 459 | let mut last_count = sprite_count % max_per_sheet; 460 | if last_count == 0 { 461 | last_count = max_per_sheet; 462 | } 463 | 464 | sheets.push(( 465 | RgbaImage::new( 466 | sheet_width, 467 | sprite_height 468 | * (f64::from(last_count) / f64::from(max_cols_per_sheet)).ceil() as u32, 469 | ), 470 | output_name( 471 | source, 472 | &args.output, 473 | Some(sheet_count - 1), 474 | &args.prefix, 475 | "png", 476 | )?, 477 | )); 478 | } 479 | 480 | // arrange sprites on sheets 481 | for (idx, sprite) in images.iter().enumerate() { 482 | if sprite.width() != sprite_width || sprite.height() != sprite_height { 483 | Err(SpriteSheetError::ImagesNotSameSize)?; 484 | } 485 | 486 | let sheet_idx = idx / max_per_sheet as usize; 487 | let sprite_idx = idx as u32 % max_per_sheet; 488 | 489 | let row = sprite_idx % cols_per_sheet; 490 | let line = sprite_idx / cols_per_sheet; 491 | 492 | let x = row * sprite_width; 493 | let y = line * sprite_height; 494 | 495 | imageops::replace(&mut sheets[sheet_idx].0, sprite, i64::from(x), i64::from(y)); 496 | } 497 | 498 | // save sheets 499 | image_util::save_sheets(&sheets, args.lossy, true)?; 500 | 501 | if args.no_crop { 502 | info!( 503 | "completed {}{name}, size: ({sprite_width}px, {sprite_height}px)", 504 | args.prefix 505 | ); 506 | } else { 507 | info!( 508 | "completed {}{name}, size: ({sprite_width}px, {sprite_height}px), shift: ({shift_x}px, {shift_y}px)", 509 | args.prefix 510 | ); 511 | } 512 | 513 | if args.lua || args.json { 514 | let mut data = LuaOutput::new() 515 | .set("width", sprite_width) 516 | .set("height", sprite_height) 517 | .set("shift", (shift_x, shift_y, args.tile_res())) 518 | .set("scale", 32.0 / args.tile_res() as f64) 519 | .set("sprite_count", sprite_count) 520 | .set("line_length", cols_per_sheet) 521 | .set("lines_per_file", rows_per_sheet) 522 | .set("file_count", sheet_count); 523 | 524 | if let Some(sequence) = frame_sequence { 525 | data = data.set("frame_sequence", sequence.as_slice()); 526 | } 527 | 528 | if args.lua { 529 | let out = output_name(source, &args.output, None, &args.prefix, "lua")?; 530 | data.save(out)?; 531 | } 532 | if args.json { 533 | let out = output_name(source, &args.output, None, &args.prefix, "json")?; 534 | data.save_as_json(out)?; 535 | } 536 | } 537 | 538 | Ok(name) 539 | } 540 | 541 | type SubframeData = (RgbaImage, (u32, u32), (f64, f64), (u32, u32)); 542 | 543 | fn generate_subframe_sheets( 544 | _args: &SpritesheetArgs, 545 | images: &[RgbaImage], 546 | sprite_width: u32, 547 | sprite_height: u32, 548 | shift_x: f64, 549 | shift_y: f64, 550 | ) -> Box<[SubframeData]> { 551 | let sprite_count = images.len() as u32; 552 | 553 | // figure out how many splits are needed (vertically / horizontally) 554 | let mut frags_x = 1; 555 | let mut frags_y = 1; 556 | 557 | loop { 558 | let frag_width = sprite_width.div_ceil(frags_x); 559 | let frag_height = sprite_height.div_ceil(frags_y); 560 | 561 | let frags_per_row = MAX_SIZE / frag_width; 562 | let frags_per_col = MAX_SIZE / frag_height; 563 | 564 | if frags_per_row * frags_per_col >= sprite_count { 565 | break; 566 | } 567 | 568 | if frag_width >= frag_height { 569 | frags_x += 1; 570 | } else { 571 | frags_y += 1; 572 | } 573 | } 574 | 575 | let frag_width = sprite_width.div_ceil(frags_x); 576 | let frag_height = sprite_height.div_ceil(frags_y); 577 | let mut frag_groups = Vec::with_capacity((frags_x * frags_y) as usize); 578 | for y in 0..frags_y { 579 | for x in 0..frags_x { 580 | // calculate dimesions, offset and shift for each subframe 581 | let tx = x * frag_width; 582 | let ty = y * frag_height; 583 | let width = frag_width.min(sprite_width - tx); 584 | let height = frag_height.min(sprite_height - ty); 585 | 586 | // frag_shift = tx + (width / 2) - (sprite_width / 2) + shift_x 587 | let frag_shift_x = 588 | (f64::from(width) - f64::from(sprite_width)).mul_add(0.5, f64::from(tx) + shift_x); 589 | let frag_shift_y = (f64::from(height) - f64::from(sprite_height)) 590 | .mul_add(0.5, f64::from(ty) + shift_y); 591 | 592 | let frags = images 593 | .iter() 594 | .map(|frame| imageops::crop_imm(frame, tx, ty, width, height)) 595 | .collect::>(); 596 | 597 | // TODO: autocrop subframes again (?) 598 | 599 | frag_groups.push((frags, (width, height), (frag_shift_x, frag_shift_y))); 600 | } 601 | } 602 | 603 | // arrange subframes on sheets 604 | frag_groups 605 | .iter() 606 | .map(|(frags, (width, height), (shift_x, shift_y))| { 607 | let cols = MAX_SIZE / width; 608 | let sheet_width = cols * width; 609 | let rows = sprite_count.div_ceil(cols); 610 | let sheet_height = rows * height; 611 | 612 | let mut sheet = RgbaImage::new(sheet_width, sheet_height); 613 | 614 | for (idx, frag) in frags.iter().enumerate() { 615 | let row = idx as u32 % cols; 616 | let line = idx as u32 / cols; 617 | 618 | let x = row * width; 619 | let y = line * height; 620 | 621 | imageops::replace(&mut sheet, &frag.to_image(), i64::from(x), i64::from(y)); 622 | } 623 | 624 | (sheet, (*width, *height), (*shift_x, *shift_y), (cols, rows)) 625 | }) 626 | .collect() 627 | } 628 | -------------------------------------------------------------------------------- /src/image_util.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | collections::HashMap, 4 | fs, 5 | io::Write, 6 | ops::Deref, 7 | path::{Path, PathBuf}, 8 | }; 9 | 10 | use image::{ 11 | codecs::png, EncodableLayout, ImageBuffer, ImageEncoder, ImageReader, PixelWithColorType, Rgba, 12 | RgbaImage, 13 | }; 14 | use imagequant::{Attributes, Histogram, HistogramEntry}; 15 | 16 | #[derive(Debug, thiserror::Error)] 17 | pub enum ImgUtilError { 18 | #[error("io error: {0}")] 19 | IOError(#[from] std::io::Error), 20 | 21 | #[error("image error: {0}")] 22 | ImageError(#[from] image::ImageError), 23 | 24 | #[error("imagequant error: {0}")] 25 | ImageQuantError(#[from] imagequant::Error), 26 | 27 | #[error("oxipng error: {0}")] 28 | OxipngError(#[from] oxipng::PngError), 29 | 30 | #[error("no images to crop")] 31 | NoImagesToCrop, 32 | 33 | #[error("all images must be the same size")] 34 | NotSameSize, 35 | 36 | #[error("unable to crop, all images are empty")] 37 | AllImagesEmpty, 38 | } 39 | 40 | type ImgUtilResult = std::result::Result; 41 | 42 | pub fn load_from_path_with_path(path: &Path) -> ImgUtilResult> { 43 | if !path.exists() { 44 | return Err(ImgUtilError::IOError(std::io::Error::new( 45 | std::io::ErrorKind::NotFound, 46 | format!("path not found: {}", path.display()), 47 | ))); 48 | } 49 | 50 | if path.is_file() && path.extension().unwrap_or_default() == "png" { 51 | return Ok(vec![(load_image_from_file(path)?, path.to_path_buf())]); 52 | } 53 | 54 | let mut images = Vec::new(); 55 | let mut files = fs::read_dir(path)? 56 | .filter_map(|res| res.map_or(None, |e| Some(e.path()))) 57 | .collect::>(); 58 | 59 | files.sort_by(|a, b| { 60 | let a = a.to_string_lossy().into_owned(); 61 | let b = b.to_string_lossy().into_owned(); 62 | natord::compare(&a, &b) 63 | }); 64 | 65 | for path in files { 66 | // skip directories, no recursive search 67 | if path.is_dir() { 68 | continue; 69 | } 70 | 71 | if path.extension().unwrap_or_default() != "png" { 72 | continue; 73 | } 74 | 75 | if !path.exists() { 76 | continue; 77 | } 78 | 79 | images.push((load_image_from_file(&path)?, path)); 80 | } 81 | 82 | Ok(images) 83 | } 84 | 85 | pub fn load_from_path(path: &Path) -> ImgUtilResult> { 86 | let res = load_from_path_with_path(path)?; 87 | Ok(res.into_iter().map(|(img, _)| img).collect()) 88 | } 89 | 90 | pub fn load_image_from_file(path: &Path) -> ImgUtilResult { 91 | trace!("loading image from {}", path.display()); 92 | let image = ImageReader::open(path)? 93 | .with_guessed_format()? 94 | .decode()? 95 | .to_rgba8(); 96 | Ok(image) 97 | } 98 | 99 | pub fn transparent_black(images: &mut [RgbaImage], black_limit: u8) { 100 | static TRANSPARENT: Rgba = Rgba([0, 0, 0, 0]); 101 | 102 | for image in images.iter_mut() { 103 | for pxl in image.pixels_mut() { 104 | if pxl[0] <= black_limit && pxl[1] <= black_limit && pxl[2] <= black_limit { 105 | *pxl = TRANSPARENT; 106 | } 107 | } 108 | } 109 | } 110 | 111 | pub fn crop_images(images: &mut Vec, alpha_limit: u8) -> ImgUtilResult<(f64, f64)> { 112 | if images.is_empty() { 113 | return Err(ImgUtilError::NoImagesToCrop); 114 | } 115 | 116 | #[allow(clippy::unwrap_used)] 117 | let (raw_width, raw_height) = images.first().unwrap().dimensions(); 118 | 119 | let mut min_x = u32::MAX; 120 | let mut min_y = u32::MAX; 121 | let mut max_x = u32::MIN; 122 | let mut max_y = u32::MIN; 123 | 124 | for image in images.iter() { 125 | // ensure image has same size 126 | if image.width() != raw_width || image.height() != raw_height { 127 | return Err(ImgUtilError::NotSameSize); 128 | } 129 | 130 | let mut x = image 131 | .enumerate_pixels() 132 | .filter_map(|(x, _, pxl)| if pxl[3] > alpha_limit { Some(x) } else { None }) 133 | .collect::>(); 134 | x.sort_unstable(); 135 | 136 | let mut y = image 137 | .enumerate_pixels() 138 | .filter_map(|(_, y, pxl)| if pxl[3] > alpha_limit { Some(y) } else { None }) 139 | .collect::>(); 140 | y.sort_unstable(); 141 | 142 | // ensure image is not empty 143 | if x.is_empty() || y.is_empty() { 144 | continue; 145 | } 146 | 147 | let local_min_x = x[0]; 148 | let local_min_y = y[0]; 149 | let local_max_x = x[x.len() - 1]; 150 | let local_max_y = y[y.len() - 1]; 151 | 152 | if min_x > local_min_x { 153 | min_x = local_min_x; 154 | } 155 | 156 | if max_x < local_max_x { 157 | max_x = local_max_x; 158 | } 159 | 160 | if min_y > local_min_y { 161 | min_y = local_min_y; 162 | } 163 | 164 | if max_y < local_max_y { 165 | max_y = local_max_y; 166 | } 167 | } 168 | 169 | // are all images empty? (or some other edge case?) 170 | if min_x == u32::MAX || min_y == u32::MAX || max_x == u32::MIN || max_y == u32::MIN { 171 | return Err(ImgUtilError::AllImagesEmpty); 172 | } 173 | 174 | // do we need to crop? 175 | if min_x == 0 && min_y == 0 && max_x == (raw_width - 1) && max_y == (raw_height - 1) { 176 | // no cropping needed 177 | return Ok((0.0, 0.0)); 178 | } 179 | 180 | let cropped_width = max_x - min_x + 1; 181 | let cropped_height = max_y - min_y + 1; 182 | 183 | debug!("cropping from {raw_width}x{raw_height} to {cropped_width}x{cropped_height}"); 184 | trace!("min_x: {min_x}, min_y: {min_y}, max_x: {max_x}, max_y: {max_y}"); 185 | 186 | // crop images 187 | for image in images { 188 | let cropped_image = 189 | image::imageops::crop_imm(image, min_x, min_y, cropped_width, cropped_height) 190 | .to_image(); 191 | *image = cropped_image; 192 | } 193 | 194 | // calculate how the center point shifted relative to the original image 195 | let mut shift_x = -((f64::from(raw_width - cropped_width) / 2.0) - f64::from(min_x)); 196 | let mut shift_y = -((f64::from(raw_height - cropped_height) / 2.0) - f64::from(min_y)); 197 | 198 | if shift_x == 0.0 { 199 | shift_x = 0.0; 200 | } 201 | 202 | if shift_y == 0.0 { 203 | shift_y = 0.0; 204 | } 205 | 206 | trace!("shifted by ({shift_x}, {shift_y})"); 207 | 208 | Ok((shift_x, shift_y)) 209 | } 210 | 211 | pub fn dedup_empty_frames(images: Vec) -> (Vec, Vec) { 212 | let mut res = Vec::with_capacity(images.len()); 213 | let mut sequence = Vec::with_capacity(images.len()); 214 | let mut first_empty_idx = usize::MAX; 215 | 216 | for image in images { 217 | let empty = image.pixels().all(|pxl| pxl[3] == 0); 218 | if empty { 219 | if first_empty_idx == usize::MAX { 220 | first_empty_idx = res.len(); 221 | res.push(image); 222 | } 223 | 224 | sequence.push(first_empty_idx); 225 | } else { 226 | sequence.push(res.len()); 227 | res.push(image); 228 | } 229 | } 230 | 231 | (res, sequence) 232 | } 233 | 234 | pub trait ImageBufferExt { 235 | fn save_optimized_png(&self, path: impl AsRef, lossy: bool) -> ImgUtilResult; 236 | 237 | fn get_histogram(&self) -> Box<[HistogramEntry]>; 238 | fn to_quant_img(&self) -> Box<[imagequant::RGBA]>; 239 | } 240 | 241 | impl ImageBufferExt, C> for ImageBuffer, C> 242 | where 243 | C: Deref, 244 | { 245 | fn save_optimized_png(&self, path: impl AsRef, lossy: bool) -> ImgUtilResult { 246 | trace!("saving image to {}", path.as_ref().display()); 247 | let (width, height) = self.dimensions(); 248 | 249 | let buf = if lossy { 250 | let quant = quantization_attributes()?; 251 | let mut img = 252 | quant.new_image(self.to_quant_img(), width as usize, height as usize, 0.0)?; 253 | 254 | let mut qres = quant.quantize(&mut img)?; 255 | qres.set_dithering_level(1.0)?; 256 | 257 | let (palette, pxls) = qres.remapped(&mut img)?; 258 | image_buf_from_palette(width, height, &convert_palette(&palette), &pxls) 259 | } else { 260 | Cow::Borrowed(self.as_bytes()) 261 | }; 262 | 263 | optimize_png(&buf, width, height, path) 264 | } 265 | 266 | fn get_histogram(&self) -> Box<[HistogramEntry]> { 267 | let mut res = HashMap::new(); 268 | 269 | for pxl in self.pixels() { 270 | let key = (pxl[0], pxl[1], pxl[2], pxl[3]); 271 | let entry = res.entry(key).or_insert(0); 272 | *entry += 1; 273 | } 274 | 275 | res.iter() 276 | .map(|(&(r, g, b, a), v)| HistogramEntry { 277 | color: imagequant::RGBA { r, g, b, a }, 278 | count: *v, 279 | }) 280 | .collect() 281 | } 282 | 283 | fn to_quant_img(&self) -> Box<[imagequant::RGBA]> { 284 | self.pixels() 285 | .map(|pxl| imagequant::RGBA { 286 | r: pxl[0], 287 | g: pxl[1], 288 | b: pxl[2], 289 | a: pxl[3], 290 | }) 291 | .collect::>() 292 | } 293 | } 294 | 295 | pub fn quantization_attributes() -> ImgUtilResult { 296 | let mut attr = Attributes::new(); 297 | attr.set_speed(1)?; 298 | 299 | Ok(attr) 300 | } 301 | 302 | /// Encode image as PNG and optimize with [oxipng] before writing to disk. 303 | pub fn optimize_png( 304 | buf: &[u8], 305 | width: u32, 306 | height: u32, 307 | path: impl AsRef, 308 | ) -> ImgUtilResult { 309 | let mut data = Vec::new(); 310 | png::PngEncoder::new_with_quality( 311 | &mut data, 312 | png::CompressionType::Fast, 313 | png::FilterType::default(), 314 | ) 315 | .write_image( 316 | buf, 317 | width, 318 | height, 319 | as PixelWithColorType>::COLOR_TYPE, 320 | )?; 321 | 322 | let mut opts = oxipng::Options::max_compression(); 323 | opts.optimize_alpha = true; 324 | opts.scale_16 = true; 325 | opts.force = true; 326 | 327 | debug!("optimizing {}", path.as_ref().display()); 328 | let res = oxipng::optimize_from_memory(&data, &opts)?; 329 | fs::File::create(path)?.write_all(&res)?; 330 | 331 | Ok(res.len() as u64) 332 | } 333 | 334 | pub fn convert_palette<'a>(palette: &[imagequant::RGBA]) -> Cow<'a, [[u8; 4]]> { 335 | palette 336 | .iter() 337 | .map(|color| [color.r, color.g, color.b, color.a]) 338 | .collect() 339 | } 340 | 341 | pub fn image_buf_from_palette<'a>( 342 | width: u32, 343 | height: u32, 344 | palette: &[[u8; 4]], 345 | pixels: &[u8], 346 | ) -> Cow<'a, [u8]> { 347 | (0..width * height) 348 | .flat_map(|i| palette[pixels[i as usize] as usize]) 349 | .collect() 350 | } 351 | 352 | /// Save sheets as PNG files. 353 | /// 354 | /// This will also optimize the images using [oxipng]. 355 | /// When `lossy` is true the images will also be compressed using [imagequant]. 356 | /// When `group` is true and there are multiple sheets it will generate a histogram and quantize ahead of time. 357 | pub fn save_sheets( 358 | sheets: &[(RgbaImage, PathBuf)], 359 | lossy: bool, 360 | group: bool, 361 | ) -> ImgUtilResult> { 362 | let sheets_count = sheets.len(); 363 | let mut sizes = Vec::with_capacity(sheets_count); 364 | // more than one sheet, lossy compression and grouping -> generate histogram and quantize ahead of time 365 | if sheets_count > 1 && lossy && group { 366 | info!("analyzing multiple images for quantization (grouped lossy compression)"); 367 | 368 | let quant = quantization_attributes()?; 369 | let mut histo = Histogram::new(&quant); 370 | 371 | for (sheet, _) in sheets { 372 | histo.add_colors(&sheet.get_histogram(), 0.0)?; 373 | } 374 | 375 | let mut qres = histo.quantize(&quant)?; 376 | qres.set_dithering_level(1.0)?; 377 | let palette = convert_palette(qres.palette()); 378 | 379 | info!("analyzing done, saving images"); 380 | 381 | for (idx, (sheet, path)) in sheets.iter().enumerate() { 382 | trace!("saving image to {}", path.display()); 383 | 384 | let (width, height) = sheet.dimensions(); 385 | let w_usize = width as usize; 386 | let h_usize = height as usize; 387 | let mut img = quant.new_image(sheet.to_quant_img(), w_usize, h_usize, 0.0)?; 388 | 389 | let mut pxls = Vec::with_capacity(w_usize * h_usize); 390 | qres.remap_into_vec(&mut img, &mut pxls)?; 391 | 392 | sizes.push(optimize_png( 393 | &image_buf_from_palette(width, height, &palette, &pxls), 394 | width, 395 | height, 396 | path, 397 | )?); 398 | 399 | if sheets_count > 10 && (idx + 1) % 10 == 0 { 400 | info!("saved {}/{sheets_count}", idx + 1); 401 | } 402 | } 403 | 404 | if sheets_count > 10 && sheets_count % 10 != 0 { 405 | info!("saved {sheets_count}/{sheets_count}"); 406 | } 407 | 408 | return Ok(sizes.into_boxed_slice()); 409 | } 410 | 411 | // regular optimized saving 412 | info!("saving image(s)"); 413 | for (idx, (sheet, path)) in sheets.iter().enumerate() { 414 | sizes.push(sheet.save_optimized_png(path, lossy)?); 415 | 416 | if sheets_count > 10 && (idx + 1) % 10 == 0 { 417 | info!("saved {}/{sheets_count}", idx + 1); 418 | } 419 | } 420 | 421 | if sheets_count > 10 && sheets_count % 10 != 0 { 422 | info!("saved {sheets_count}/{sheets_count}"); 423 | } 424 | 425 | Ok(sizes.into_boxed_slice()) 426 | } 427 | -------------------------------------------------------------------------------- /src/logger.rs: -------------------------------------------------------------------------------- 1 | // This is a modified version of pretty_env_logger v0.4.0 that uses Builder::from_env() 2 | 3 | use std::fmt; 4 | use std::sync::atomic::{AtomicUsize, Ordering}; 5 | 6 | use env_logger::{ 7 | fmt::{Color, Style, StyledValue}, 8 | Builder, Env, 9 | }; 10 | use log::Level; 11 | 12 | pub fn init(level: &str) { 13 | let env = Env::default().filter_or("RUST_LOG", level); 14 | 15 | Builder::from_env(env) 16 | .format(|buf, record| { 17 | use std::io::Write; 18 | 19 | let target = record.target(); 20 | let max_width = max_target_width(target); 21 | 22 | let mut style = buf.style(); 23 | let level = colored_level(&mut style, record.level()); 24 | 25 | let mut style = buf.style(); 26 | let target = style.set_bold(true).value(Padded { 27 | value: target, 28 | width: max_width, 29 | }); 30 | 31 | let time = buf.timestamp_millis(); 32 | let text = record.args().to_string(); 33 | 34 | let target_pad = Padded { 35 | value: " ", 36 | width: max_width, 37 | }; 38 | 39 | // 24 (timestamp) + 5 (level) + 1 space = 30 40 | let newline_padding = format!("{:30} {}", " ", target_pad); 41 | let lines: Vec<_> = text.lines().collect(); 42 | 43 | writeln!(buf, "{} {} {} > {}", time, level, target, lines[0])?; 44 | 45 | for line in &lines[1..] { 46 | writeln!(buf, "{newline_padding} {line}")?; 47 | } 48 | 49 | Ok(()) 50 | }) 51 | .init(); 52 | } 53 | 54 | struct Padded { 55 | value: T, 56 | width: usize, 57 | } 58 | 59 | impl fmt::Display for Padded { 60 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 61 | write!(f, "{: usize { 68 | let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed); 69 | if max_width < target.len() { 70 | MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed); 71 | target.len() 72 | } else { 73 | max_width 74 | } 75 | } 76 | 77 | fn colored_level(style: &mut Style, level: Level) -> StyledValue<&'static str> { 78 | match level { 79 | Level::Trace => style.set_color(Color::Magenta).value("TRACE"), 80 | Level::Debug => style.set_color(Color::Blue).value("DEBUG"), 81 | Level::Info => style.set_color(Color::Green).value("INFO "), 82 | Level::Warn => style.set_color(Color::Yellow).value("WARN "), 83 | Level::Error => style.set_color(Color::Red).value("ERROR"), 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lua.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeMap, io::Write, path::Path}; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum LuaValue { 5 | String(String), 6 | Float(f64), 7 | Int(i64), 8 | Bool(bool), 9 | Shift(f64, f64, usize), 10 | Array(Box<[LuaValue]>), 11 | Table(LuaOutput), 12 | } 13 | 14 | impl From for LuaValue { 15 | fn from(value: String) -> Self { 16 | Self::String(value) 17 | } 18 | } 19 | 20 | impl From<&str> for LuaValue { 21 | fn from(value: &str) -> Self { 22 | Self::String(value.to_owned()) 23 | } 24 | } 25 | 26 | impl From for LuaValue { 27 | fn from(value: f64) -> Self { 28 | Self::Float(value) 29 | } 30 | } 31 | 32 | impl From for LuaValue { 33 | fn from(value: f32) -> Self { 34 | Self::Float(value as f64) 35 | } 36 | } 37 | 38 | impl From for LuaValue { 39 | fn from(value: isize) -> Self { 40 | Self::Int(value as i64) 41 | } 42 | } 43 | 44 | impl From for LuaValue { 45 | fn from(value: i64) -> Self { 46 | Self::Int(value) 47 | } 48 | } 49 | 50 | impl From for LuaValue { 51 | fn from(value: i32) -> Self { 52 | Self::Int(value as i64) 53 | } 54 | } 55 | 56 | impl From for LuaValue { 57 | fn from(value: i16) -> Self { 58 | Self::Int(value as i64) 59 | } 60 | } 61 | 62 | impl From for LuaValue { 63 | fn from(value: i8) -> Self { 64 | Self::Int(value as i64) 65 | } 66 | } 67 | 68 | impl From for LuaValue { 69 | fn from(value: usize) -> Self { 70 | Self::Int(value as i64) 71 | } 72 | } 73 | 74 | impl From for LuaValue { 75 | fn from(value: u64) -> Self { 76 | Self::Int(value as i64) 77 | } 78 | } 79 | 80 | impl From for LuaValue { 81 | fn from(value: u32) -> Self { 82 | Self::Int(value as i64) 83 | } 84 | } 85 | 86 | impl From for LuaValue { 87 | fn from(value: u16) -> Self { 88 | Self::Int(value as i64) 89 | } 90 | } 91 | 92 | impl From for LuaValue { 93 | fn from(value: u8) -> Self { 94 | Self::Int(value as i64) 95 | } 96 | } 97 | 98 | impl From for LuaValue { 99 | fn from(value: bool) -> Self { 100 | Self::Bool(value) 101 | } 102 | } 103 | 104 | impl + Clone> From<&[T]> for LuaValue { 105 | fn from(value: &[T]) -> Self { 106 | Self::Array(value.iter().cloned().map(Into::into).collect()) 107 | } 108 | } 109 | 110 | impl From<(f64, f64, usize)> for LuaValue { 111 | fn from((shift_x, shift_y, res): (f64, f64, usize)) -> Self { 112 | Self::Shift(shift_x, shift_y, res) 113 | } 114 | } 115 | 116 | impl From for LuaValue { 117 | fn from(value: LuaOutput) -> Self { 118 | Self::Table(value) 119 | } 120 | } 121 | 122 | impl + Clone> From<&T> for LuaValue { 123 | fn from(value: &T) -> Self { 124 | value.clone().into() 125 | } 126 | } 127 | 128 | impl LuaValue { 129 | fn gen_lua(&self, f: &mut dyn Write) -> std::io::Result<()> { 130 | match self { 131 | Self::String(value) => write!(f, "\"{value}\""), 132 | Self::Float(value) => write!(f, "{value}"), 133 | Self::Int(value) => write!(f, "{value}"), 134 | Self::Bool(value) => write!(f, "{value}"), 135 | Self::Shift(x, y, res) => write!(f, "{{{x} / {res}, {y} / {res}}}"), 136 | Self::Array(arr) => { 137 | write!(f, "{{")?; 138 | for value in arr { 139 | value.gen_lua(f)?; 140 | write!(f, ",")?; 141 | } 142 | write!(f, "}}") 143 | } 144 | Self::Table(table) => table.gen_lua(f), 145 | } 146 | } 147 | 148 | fn gen_json(&self, f: &mut dyn Write) -> std::io::Result<()> { 149 | match self { 150 | Self::String(value) => write!(f, "\"{value}\""), 151 | Self::Float(value) => write!(f, "{value}"), 152 | Self::Int(value) => write!(f, "{value}"), 153 | Self::Bool(value) => write!(f, "{value}"), 154 | Self::Shift(x, y, res) => { 155 | let x = x / *res as f64; 156 | let y = y / *res as f64; 157 | write!(f, "[{x},{y}]") 158 | } 159 | Self::Array(arr) => { 160 | write!(f, "[")?; 161 | let len = arr.len(); 162 | for (i, value) in arr.iter().enumerate() { 163 | value.gen_json(f)?; 164 | if i < len - 1 { 165 | write!(f, ",")?; 166 | } 167 | } 168 | write!(f, "]") 169 | } 170 | Self::Table(table) => table.gen_json(f), 171 | } 172 | } 173 | } 174 | 175 | #[derive(Debug, Clone)] 176 | pub struct LuaOutput { 177 | map: BTreeMap, 178 | } 179 | 180 | impl LuaOutput { 181 | pub const fn new() -> Self { 182 | Self { 183 | map: BTreeMap::new(), 184 | } 185 | } 186 | 187 | pub fn set(mut self, key: impl AsRef, value: impl Into) -> Self { 188 | self.map.insert(key.as_ref().to_owned(), value.into()); 189 | self 190 | } 191 | 192 | pub fn save(&self, path: impl AsRef) -> std::io::Result<()> { 193 | let mut file = std::fs::File::create(path)?; 194 | 195 | writeln!( 196 | file, 197 | "-- Generated by {} v{} - {}", 198 | env!("CARGO_PKG_NAME"), 199 | env!("CARGO_PKG_VERSION"), 200 | env!("CARGO_PKG_REPOSITORY") 201 | )?; 202 | writeln!(file, "return {{")?; 203 | writeln!( 204 | file, 205 | " [\"spritter\"] = {{ {}, {}, {} }},", 206 | env!("CARGO_PKG_VERSION_MAJOR"), 207 | env!("CARGO_PKG_VERSION_MINOR"), 208 | env!("CARGO_PKG_VERSION_PATCH") 209 | )?; 210 | 211 | for (key, data) in &self.map { 212 | write!(file, " [\"{key}\"] = ")?; 213 | data.gen_lua(&mut file)?; 214 | writeln!(file, ",")?; 215 | } 216 | 217 | writeln!(file, "}}")?; 218 | 219 | Ok(()) 220 | } 221 | 222 | pub fn save_as_json(&self, path: impl AsRef) -> std::io::Result<()> { 223 | let mut file = std::fs::File::create(path)?; 224 | 225 | writeln!(file, "{{")?; 226 | writeln!( 227 | file, 228 | " \"spritter\": [{},{},{}],", 229 | env!("CARGO_PKG_VERSION_MAJOR"), 230 | env!("CARGO_PKG_VERSION_MINOR"), 231 | env!("CARGO_PKG_VERSION_PATCH") 232 | )?; 233 | 234 | let len = self.map.len(); 235 | for (index, (key, data)) in self.map.iter().enumerate() { 236 | write!(file, " \"{key}\": ")?; 237 | data.gen_json(&mut file)?; 238 | if index < len - 1 { 239 | writeln!(file, ",")?; 240 | } else { 241 | writeln!(file)?; 242 | } 243 | } 244 | 245 | writeln!(file, "}}")?; 246 | 247 | Ok(()) 248 | } 249 | 250 | fn gen_lua(&self, f: &mut dyn Write) -> std::io::Result<()> { 251 | write!(f, "{{")?; 252 | 253 | for (key, data) in &self.map { 254 | write!(f, "[\"{key}\"] = ")?; 255 | data.gen_lua(f)?; 256 | write!(f, ",")?; 257 | } 258 | 259 | write!(f, "}}")?; 260 | 261 | Ok(()) 262 | } 263 | 264 | fn gen_json(&self, f: &mut dyn Write) -> std::io::Result<()> { 265 | write!(f, "{{")?; 266 | 267 | let len = self.map.len(); 268 | for (index, (key, data)) in self.map.iter().enumerate() { 269 | write!(f, "\"{key}\": ")?; 270 | data.gen_json(f)?; 271 | if index < len - 1 { 272 | write!(f, ",")?; 273 | } else { 274 | writeln!(f)?; 275 | } 276 | } 277 | 278 | write!(f, "}}")?; 279 | 280 | Ok(()) 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitCode; 2 | 3 | use clap::Parser; 4 | 5 | #[macro_use] 6 | extern crate log; 7 | 8 | mod commands; 9 | mod image_util; 10 | mod logger; 11 | mod lua; 12 | 13 | use commands::{generate_gif, generate_mipmap_icon, optimize, split, GenerationCommand}; 14 | 15 | #[derive(Parser, Debug)] 16 | #[command(version, about, long_about=None)] 17 | struct Cli { 18 | #[clap(subcommand)] 19 | command: GenerationCommand, 20 | } 21 | 22 | fn main() -> ExitCode { 23 | let args = Cli::parse(); 24 | logger::init("info,oxipng=warn"); 25 | info!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); 26 | 27 | let res = match args.command { 28 | GenerationCommand::Spritesheet { args } => args.execute(), 29 | GenerationCommand::Icon { args } => generate_mipmap_icon(&args), 30 | GenerationCommand::Gif { args } => generate_gif(&args), 31 | GenerationCommand::Optimize { args } => optimize(&args), 32 | GenerationCommand::Split { args } => split(&args), 33 | }; 34 | 35 | if let Err(err) = res { 36 | error!("{err}"); 37 | return ExitCode::FAILURE; 38 | } 39 | 40 | ExitCode::SUCCESS 41 | } 42 | --------------------------------------------------------------------------------