├── .github ├── dependabot.yml └── workflows │ └── rust.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── gifski.h ├── gifski.xcodeproj └── project.pbxproj ├── snapcraft.yaml ├── src ├── bin │ ├── ffmpeg_source.rs │ ├── gif_source.rs │ ├── gifski.rs │ ├── png.rs │ ├── source.rs │ └── y4m_source.rs ├── c_api.rs ├── c_api │ └── c_api_error.rs ├── collector.rs ├── denoise.rs ├── encoderust.rs ├── error.rs ├── gifsicle.rs ├── lib.rs ├── minipool.rs └── progress.rs └── tests ├── 1.png ├── 2.png ├── 3.png ├── a2 ├── 01.png ├── 02.png ├── 03.png ├── 04.png ├── 05.png ├── 06.png ├── 07.png ├── 08.png ├── 09.png ├── 10.png ├── 11.png ├── 12.png ├── 13.png ├── 14.png ├── 15.png ├── 16.png ├── 17.png ├── 18.png ├── 19.png ├── 20.png ├── 21.png ├── 22.png ├── 23.png ├── 24.png ├── 25.png ├── 26.png ├── 27.png ├── 28.png ├── 29.png ├── 30.png ├── 31.png ├── 32.png ├── 33.png ├── 34.png ├── 35.png ├── 36.png ├── 37.png ├── 38.png ├── 39.png ├── 40.png ├── 41.png ├── 42.png └── 43.png ├── a3 ├── x0.png ├── x1.png ├── x2.png ├── x3.png ├── x4.png ├── x5.png ├── y0.png ├── y1.png ├── y2.png ├── y3.png ├── y4.png ├── y5.png ├── z0.png ├── z1.png ├── z2.png ├── z3.png ├── z4.png └── z5.png └── tests.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: lodepng 10 | versions: 11 | - 3.4.5 12 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Run tests 17 | run: cargo test --verbose 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | **/*.rs.bk 3 | 4 | # Snap packaging specific rules 5 | /snap/.snapcraft/ 6 | /parts/ 7 | /stage/ 8 | /prime/ 9 | 10 | /*.snap 11 | /*_source.tar.bz2 12 | *.sh 13 | .cargo/ 14 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Rustfmt makes too many mistakes to be applied unconditionally to the whole code. 2 | # Please don't apply rustfmt to code in this project. Disable rustfmt in your editor. 3 | disable_all_formatting = true 4 | ignore = ["/"] 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 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 = "anstream" 22 | version = "0.6.18" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "is_terminal_polyfill", 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle" 37 | version = "1.0.10" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 40 | 41 | [[package]] 42 | name = "anstyle-parse" 43 | version = "0.2.6" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 46 | dependencies = [ 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-query" 52 | version = "1.1.2" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 55 | dependencies = [ 56 | "windows-sys", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-wincon" 61 | version = "3.0.7" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 64 | dependencies = [ 65 | "anstyle", 66 | "once_cell", 67 | "windows-sys", 68 | ] 69 | 70 | [[package]] 71 | name = "arrayvec" 72 | version = "0.7.6" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 75 | 76 | [[package]] 77 | name = "autocfg" 78 | version = "1.4.0" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 81 | 82 | [[package]] 83 | name = "bindgen" 84 | version = "0.64.0" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "c4243e6031260db77ede97ad86c27e501d646a27ab57b59a574f725d98ab1fb4" 87 | dependencies = [ 88 | "bitflags", 89 | "cexpr", 90 | "clang-sys", 91 | "lazy_static", 92 | "lazycell", 93 | "peeking_take_while", 94 | "proc-macro2", 95 | "quote", 96 | "regex", 97 | "rustc-hash", 98 | "shlex", 99 | "syn", 100 | ] 101 | 102 | [[package]] 103 | name = "bitflags" 104 | version = "1.3.2" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 107 | 108 | [[package]] 109 | name = "bytemuck" 110 | version = "1.22.0" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" 113 | 114 | [[package]] 115 | name = "cc" 116 | version = "1.2.19" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" 119 | dependencies = [ 120 | "shlex", 121 | ] 122 | 123 | [[package]] 124 | name = "cexpr" 125 | version = "0.6.0" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" 128 | dependencies = [ 129 | "nom", 130 | ] 131 | 132 | [[package]] 133 | name = "cfg-if" 134 | version = "1.0.0" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 137 | 138 | [[package]] 139 | name = "clang-sys" 140 | version = "1.8.1" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" 143 | dependencies = [ 144 | "glob", 145 | "libc", 146 | "libloading", 147 | ] 148 | 149 | [[package]] 150 | name = "clap" 151 | version = "4.5.36" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" 154 | dependencies = [ 155 | "clap_builder", 156 | ] 157 | 158 | [[package]] 159 | name = "clap_builder" 160 | version = "4.5.36" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" 163 | dependencies = [ 164 | "anstream", 165 | "anstyle", 166 | "clap_lex", 167 | "strsim", 168 | ] 169 | 170 | [[package]] 171 | name = "clap_lex" 172 | version = "0.7.4" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 175 | 176 | [[package]] 177 | name = "colorchoice" 178 | version = "1.0.3" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 181 | 182 | [[package]] 183 | name = "crc32fast" 184 | version = "1.4.2" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" 187 | dependencies = [ 188 | "cfg-if", 189 | ] 190 | 191 | [[package]] 192 | name = "crossbeam-channel" 193 | version = "0.5.15" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 196 | dependencies = [ 197 | "crossbeam-utils", 198 | ] 199 | 200 | [[package]] 201 | name = "crossbeam-deque" 202 | version = "0.8.6" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 205 | dependencies = [ 206 | "crossbeam-epoch", 207 | "crossbeam-utils", 208 | ] 209 | 210 | [[package]] 211 | name = "crossbeam-epoch" 212 | version = "0.9.18" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 215 | dependencies = [ 216 | "crossbeam-utils", 217 | ] 218 | 219 | [[package]] 220 | name = "crossbeam-utils" 221 | version = "0.8.21" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 224 | 225 | [[package]] 226 | name = "dunce" 227 | version = "1.0.5" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 230 | 231 | [[package]] 232 | name = "either" 233 | version = "1.15.0" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 236 | 237 | [[package]] 238 | name = "ffmpeg-next" 239 | version = "6.1.1" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "4e72c72e8dcf638fb0fb03f033a954691662b5dabeaa3f85a6607d101569fccd" 242 | dependencies = [ 243 | "bitflags", 244 | "ffmpeg-sys-next", 245 | "libc", 246 | ] 247 | 248 | [[package]] 249 | name = "ffmpeg-sys-next" 250 | version = "6.1.0" 251 | source = "git+https://github.com/kornelski/rust-ffmpeg-sys-1?rev=fd5784d645df2ebe022a204ac36582074da1edf7#fd5784d645df2ebe022a204ac36582074da1edf7" 252 | dependencies = [ 253 | "bindgen", 254 | "cc", 255 | "libc", 256 | "num_cpus", 257 | "pkg-config", 258 | "vcpkg", 259 | ] 260 | 261 | [[package]] 262 | name = "flate2" 263 | version = "1.1.1" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" 266 | dependencies = [ 267 | "crc32fast", 268 | "miniz_oxide", 269 | ] 270 | 271 | [[package]] 272 | name = "gif" 273 | version = "0.13.1" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" 276 | dependencies = [ 277 | "weezl", 278 | ] 279 | 280 | [[package]] 281 | name = "gif-dispose" 282 | version = "5.0.1" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "5e1aa07391f3d9c279f388cea6faf291555dd891df59bed01d4378583df946ac" 285 | dependencies = [ 286 | "gif", 287 | "imgref", 288 | "rgb", 289 | ] 290 | 291 | [[package]] 292 | name = "gifski" 293 | version = "1.34.0" 294 | dependencies = [ 295 | "clap", 296 | "crossbeam-channel", 297 | "crossbeam-utils", 298 | "dunce", 299 | "ffmpeg-next", 300 | "gif", 301 | "gif-dispose", 302 | "imagequant", 303 | "imgref", 304 | "lodepng", 305 | "loop9", 306 | "natord", 307 | "num-traits", 308 | "ordered-channel", 309 | "pbr", 310 | "quick-error", 311 | "resize", 312 | "rgb", 313 | "wild", 314 | "y4m", 315 | "yuv", 316 | ] 317 | 318 | [[package]] 319 | name = "glob" 320 | version = "0.3.2" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" 323 | 324 | [[package]] 325 | name = "hermit-abi" 326 | version = "0.3.9" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 329 | 330 | [[package]] 331 | name = "imagequant" 332 | version = "4.3.4" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "1cede25dbe6a6c3842989fa341cba6e2a4dc33ba12a33f553baeed257f965cb5" 335 | dependencies = [ 336 | "arrayvec", 337 | "once_cell", 338 | "rayon", 339 | "rgb", 340 | "thread_local", 341 | ] 342 | 343 | [[package]] 344 | name = "imgref" 345 | version = "1.11.0" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" 348 | 349 | [[package]] 350 | name = "is_terminal_polyfill" 351 | version = "1.70.1" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 354 | 355 | [[package]] 356 | name = "lazy_static" 357 | version = "1.5.0" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 360 | 361 | [[package]] 362 | name = "lazycell" 363 | version = "1.3.0" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" 366 | 367 | [[package]] 368 | name = "libc" 369 | version = "0.2.171" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" 372 | 373 | [[package]] 374 | name = "libloading" 375 | version = "0.8.6" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" 378 | dependencies = [ 379 | "cfg-if", 380 | "windows-targets", 381 | ] 382 | 383 | [[package]] 384 | name = "lodepng" 385 | version = "3.11.0" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "a7720115060cd38dcfe5c758525a43fd34dc615d0566374212ff0dc3b6151eac" 388 | dependencies = [ 389 | "crc32fast", 390 | "flate2", 391 | "libc", 392 | "rgb", 393 | ] 394 | 395 | [[package]] 396 | name = "loop9" 397 | version = "0.1.5" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" 400 | dependencies = [ 401 | "imgref", 402 | ] 403 | 404 | [[package]] 405 | name = "memchr" 406 | version = "2.7.4" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 409 | 410 | [[package]] 411 | name = "minimal-lexical" 412 | version = "0.2.1" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 415 | 416 | [[package]] 417 | name = "miniz_oxide" 418 | version = "0.8.8" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 421 | dependencies = [ 422 | "adler2", 423 | ] 424 | 425 | [[package]] 426 | name = "natord" 427 | version = "1.0.9" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" 430 | 431 | [[package]] 432 | name = "nom" 433 | version = "7.1.3" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 436 | dependencies = [ 437 | "memchr", 438 | "minimal-lexical", 439 | ] 440 | 441 | [[package]] 442 | name = "num-traits" 443 | version = "0.2.19" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 446 | dependencies = [ 447 | "autocfg", 448 | ] 449 | 450 | [[package]] 451 | name = "num_cpus" 452 | version = "1.16.0" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" 455 | dependencies = [ 456 | "hermit-abi", 457 | "libc", 458 | ] 459 | 460 | [[package]] 461 | name = "once_cell" 462 | version = "1.21.3" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 465 | 466 | [[package]] 467 | name = "ordered-channel" 468 | version = "1.2.0" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" 471 | dependencies = [ 472 | "crossbeam-channel", 473 | ] 474 | 475 | [[package]] 476 | name = "pbr" 477 | version = "1.1.1" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "ed5827dfa0d69b6c92493d6c38e633bbaa5937c153d0d7c28bf12313f8c6d514" 480 | dependencies = [ 481 | "crossbeam-channel", 482 | "libc", 483 | "winapi", 484 | ] 485 | 486 | [[package]] 487 | name = "peeking_take_while" 488 | version = "0.1.2" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" 491 | 492 | [[package]] 493 | name = "pkg-config" 494 | version = "0.3.32" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 497 | 498 | [[package]] 499 | name = "proc-macro2" 500 | version = "1.0.94" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 503 | dependencies = [ 504 | "unicode-ident", 505 | ] 506 | 507 | [[package]] 508 | name = "quick-error" 509 | version = "2.0.1" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 512 | 513 | [[package]] 514 | name = "quote" 515 | version = "1.0.40" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 518 | dependencies = [ 519 | "proc-macro2", 520 | ] 521 | 522 | [[package]] 523 | name = "rayon" 524 | version = "1.10.0" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 527 | dependencies = [ 528 | "either", 529 | "rayon-core", 530 | ] 531 | 532 | [[package]] 533 | name = "rayon-core" 534 | version = "1.12.1" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 537 | dependencies = [ 538 | "crossbeam-deque", 539 | "crossbeam-utils", 540 | ] 541 | 542 | [[package]] 543 | name = "regex" 544 | version = "1.11.1" 545 | source = "registry+https://github.com/rust-lang/crates.io-index" 546 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 547 | dependencies = [ 548 | "aho-corasick", 549 | "memchr", 550 | "regex-automata", 551 | "regex-syntax", 552 | ] 553 | 554 | [[package]] 555 | name = "regex-automata" 556 | version = "0.4.9" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 559 | dependencies = [ 560 | "aho-corasick", 561 | "memchr", 562 | "regex-syntax", 563 | ] 564 | 565 | [[package]] 566 | name = "regex-syntax" 567 | version = "0.8.5" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 570 | 571 | [[package]] 572 | name = "resize" 573 | version = "0.8.8" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "87a103d0b47e783f4579149402f7499397ab25540c7a57b2f70487a5d2d20ef0" 576 | dependencies = [ 577 | "rayon", 578 | "rgb", 579 | ] 580 | 581 | [[package]] 582 | name = "rgb" 583 | version = "0.8.50" 584 | source = "registry+https://github.com/rust-lang/crates.io-index" 585 | checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" 586 | dependencies = [ 587 | "bytemuck", 588 | ] 589 | 590 | [[package]] 591 | name = "rustc-hash" 592 | version = "1.1.0" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 595 | 596 | [[package]] 597 | name = "shlex" 598 | version = "1.3.0" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 601 | 602 | [[package]] 603 | name = "strsim" 604 | version = "0.11.1" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 607 | 608 | [[package]] 609 | name = "syn" 610 | version = "1.0.109" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 613 | dependencies = [ 614 | "proc-macro2", 615 | "quote", 616 | "unicode-ident", 617 | ] 618 | 619 | [[package]] 620 | name = "thread_local" 621 | version = "1.1.8" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 624 | dependencies = [ 625 | "cfg-if", 626 | "once_cell", 627 | ] 628 | 629 | [[package]] 630 | name = "unicode-ident" 631 | version = "1.0.18" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 634 | 635 | [[package]] 636 | name = "utf8parse" 637 | version = "0.2.2" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 640 | 641 | [[package]] 642 | name = "vcpkg" 643 | version = "0.2.15" 644 | source = "registry+https://github.com/rust-lang/crates.io-index" 645 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 646 | 647 | [[package]] 648 | name = "weezl" 649 | version = "0.1.8" 650 | source = "registry+https://github.com/rust-lang/crates.io-index" 651 | checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" 652 | 653 | [[package]] 654 | name = "wild" 655 | version = "2.2.1" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "a3131afc8c575281e1e80f36ed6a092aa502c08b18ed7524e86fbbb12bb410e1" 658 | dependencies = [ 659 | "glob", 660 | ] 661 | 662 | [[package]] 663 | name = "winapi" 664 | version = "0.3.9" 665 | source = "registry+https://github.com/rust-lang/crates.io-index" 666 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 667 | dependencies = [ 668 | "winapi-i686-pc-windows-gnu", 669 | "winapi-x86_64-pc-windows-gnu", 670 | ] 671 | 672 | [[package]] 673 | name = "winapi-i686-pc-windows-gnu" 674 | version = "0.4.0" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 677 | 678 | [[package]] 679 | name = "winapi-x86_64-pc-windows-gnu" 680 | version = "0.4.0" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 683 | 684 | [[package]] 685 | name = "windows-sys" 686 | version = "0.59.0" 687 | source = "registry+https://github.com/rust-lang/crates.io-index" 688 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 689 | dependencies = [ 690 | "windows-targets", 691 | ] 692 | 693 | [[package]] 694 | name = "windows-targets" 695 | version = "0.52.6" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 698 | dependencies = [ 699 | "windows_aarch64_gnullvm", 700 | "windows_aarch64_msvc", 701 | "windows_i686_gnu", 702 | "windows_i686_gnullvm", 703 | "windows_i686_msvc", 704 | "windows_x86_64_gnu", 705 | "windows_x86_64_gnullvm", 706 | "windows_x86_64_msvc", 707 | ] 708 | 709 | [[package]] 710 | name = "windows_aarch64_gnullvm" 711 | version = "0.52.6" 712 | source = "registry+https://github.com/rust-lang/crates.io-index" 713 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 714 | 715 | [[package]] 716 | name = "windows_aarch64_msvc" 717 | version = "0.52.6" 718 | source = "registry+https://github.com/rust-lang/crates.io-index" 719 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 720 | 721 | [[package]] 722 | name = "windows_i686_gnu" 723 | version = "0.52.6" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 726 | 727 | [[package]] 728 | name = "windows_i686_gnullvm" 729 | version = "0.52.6" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 732 | 733 | [[package]] 734 | name = "windows_i686_msvc" 735 | version = "0.52.6" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 738 | 739 | [[package]] 740 | name = "windows_x86_64_gnu" 741 | version = "0.52.6" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 744 | 745 | [[package]] 746 | name = "windows_x86_64_gnullvm" 747 | version = "0.52.6" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 750 | 751 | [[package]] 752 | name = "windows_x86_64_msvc" 753 | version = "0.52.6" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 756 | 757 | [[package]] 758 | name = "y4m" 759 | version = "0.8.0" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" 762 | 763 | [[package]] 764 | name = "yuv" 765 | version = "0.1.9" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "8cbe2d856acbe6d86c0fa6f458b73e962834061ca2f7f94c6e4633afc9efd4d4" 768 | dependencies = [ 769 | "num-traits", 770 | "rgb", 771 | ] 772 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Kornel "] 3 | categories = ["multimedia::video", "command-line-utilities"] 4 | description = "pngquant-based GIF maker for nice-looking animGIFs" 5 | documentation = "https://docs.rs/gifski" 6 | homepage = "https://gif.ski" 7 | include = ["/README.md", "/Cargo.toml", "/src/**/*.rs", "/src/bin/*.rs"] 8 | keywords = ["gif", "encoder", "converter", "maker", "gifquant"] 9 | license = "AGPL-3.0-or-later" 10 | name = "gifski" 11 | readme = "README.md" 12 | repository = "https://github.com/ImageOptim/gifski" 13 | version = "1.34.0" 14 | autobins = false 15 | edition = "2021" 16 | rust-version = "1.74" 17 | 18 | [[bin]] 19 | doctest = false 20 | name = "gifski" 21 | required-features = ["binary"] 22 | 23 | [dependencies] 24 | clap = { version = "4.5.32", features = ["cargo"], optional = true } 25 | gif = { version = "0.13.1", default-features = false, features = ["std", "raii_no_panic"] } 26 | gif-dispose = "5.0.1" 27 | imagequant = "4.3.4" 28 | lodepng = { version = "3.11.0", optional = true } 29 | natord = { version = "1.0.9", optional = true } 30 | pbr = { version = "1.1.1", optional = true } 31 | quick-error = "2.0.1" 32 | resize = { version = "0.8.8", features = ["rayon"] } 33 | rgb = { version = "0.8.50", default-features = false, features = ["bytemuck"] } 34 | dunce = { version = "1.0.5", optional = true } 35 | crossbeam-channel = "0.5.14" 36 | imgref = "1.11.0" 37 | loop9 = "0.1.5" 38 | # noisy-float 0.2 bug 39 | num-traits = { version = "0.2.19", features = ["i128", "std"] } 40 | crossbeam-utils = "0.8.21" 41 | ordered-channel = { version = "1.2.0", features = ["crossbeam-channel"] } 42 | wild = { version = "2.2.1", optional = true, features = ["glob-quoted-on-windows"] } 43 | y4m = { version = "0.8.0", optional = true } 44 | yuv = { version = "0.1.9", optional = true } 45 | 46 | [dependencies.ffmpeg] 47 | package = "ffmpeg-next" 48 | version = "6" 49 | optional = true 50 | default-features = false 51 | features = ["codec", "format", "filter", "software-resampling", "software-scaling"] 52 | 53 | [dev-dependencies] 54 | lodepng = "3.11.0" 55 | 56 | [features] 57 | # `cargo build` will skip the binaries with missing `required-features` 58 | # so all CLI dependencies have to be enabled by default. 59 | default = ["gifsicle", "binary"] 60 | # You can disable this feture when using gifski as a library. 61 | binary = ["dep:clap", "dep:yuv", "dep:y4m", "png", "pbr", "dep:wild", "dep:natord", "dep:dunce"] 62 | capi = [] # internal for cargo-c only 63 | png = ["dep:lodepng"] 64 | # Links dynamically to ffmpeg. Needs ffmpeg devel package installed on the system. 65 | video = ["dep:ffmpeg"] 66 | # Builds ffmpeg from source. Needs a C compiler, and all of ffmpeg's source dependencies. 67 | video-static = ["video", "ffmpeg/build"] 68 | # If you're lucky, this one might work with ffmpeg from vcpkg. 69 | video-prebuilt-static = ["video", "ffmpeg/static"] 70 | # Support lossy LZW encoding when lower quality is set 71 | gifsicle = [] 72 | 73 | [lib] 74 | path = "src/lib.rs" 75 | crate-type = ["lib", "staticlib", "cdylib"] 76 | 77 | [profile.dev] 78 | debug = 1 79 | opt-level = 1 80 | 81 | [profile.dev.package.'*'] 82 | opt-level = 2 83 | debug = false 84 | 85 | [profile.release] 86 | panic = "abort" 87 | lto = true 88 | debug = false 89 | opt-level = 3 90 | strip = true 91 | 92 | [package.metadata.docs.rs] 93 | targets = ["x86_64-unknown-linux-gnu"] 94 | 95 | [package.metadata.capi.header] 96 | subdirectory = false 97 | generation = false 98 | 99 | [package.metadata.capi.install.include] 100 | asset = [{from = "gifski.h"}] 101 | 102 | [patch.crates-io] 103 | # ffmpeg-sys-next does not support cross-compilation, which I use to produce binaries https://github.com/zmwangx/rust-ffmpeg-sys/pull/30 104 | ffmpeg-sys-next = { rev = "fd5784d645df2ebe022a204ac36582074da1edf7", git = "https://github.com/kornelski/rust-ffmpeg-sys-1"} 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [gif.ski](https://gif.ski) 2 | 3 | Highest-quality GIF encoder based on [pngquant](https://pngquant.org). 4 | 5 | **[gifski](https://gif.ski)** converts video frames to GIF animations using pngquant's fancy features for efficient cross-frame palettes and temporal dithering. It produces animated GIFs that use thousands of colors per frame. 6 | 7 | ![(CC) Blender Foundation | gooseberry.blender.org](https://gif.ski/demo.gif) 8 | 9 | It's a CLI tool, but it can also be compiled [as a C library](https://docs.rs/gifski) for seamless use in other apps. 10 | 11 | ## Download and install 12 | 13 | See [releases](https://github.com/ImageOptim/gifski/releases) page for executables. 14 | 15 | If you have [Homebrew](https://brew.sh/), you can also get it with `brew install gifski`. 16 | 17 | If you have [Rust from rustup](https://www.rust-lang.org/install.html) (1.63+), you can also build it from source with [`cargo install gifski`](https://lib.rs/crates/gifski). 18 | 19 | ## Usage 20 | 21 | gifski is a command-line tool. If you're not comfortable with a terminal, try the GUI version for [Windows][winmsi] or for [macOS][macapp]. 22 | 23 | [winmsi]: https://github.com/ImageOptim/gifski/releases/download/1.14.4/gifski_1.14.4_x64_en-US.msi 24 | [macapp]: https://sindresorhus.com/gifski 25 | 26 | ### From ffmpeg video 27 | 28 | > Tip: Instead of typing file paths, you can drag'n'drop files into the terminal window! 29 | 30 | If you have ffmpeg installed, you can use it to stream a video directly to the gifski command by adding `-f yuv4mpegpipe` to `ffmpeg`'s arguments: 31 | 32 | ```sh 33 | ffmpeg -i video.mp4 -f yuv4mpegpipe - | gifski -o anim.gif - 34 | ``` 35 | 36 | Replace "video.mp4" in the above code with actual path to your video. 37 | 38 | Note that there's `-` at the end of the command. This tells `gifski` to read from standard input. Reading a `.y4m` file from disk would work too, but these files are huge. 39 | 40 | `gifski` may automatically downsize the video if it has resolution too high for a GIF. Use `--width=1280` if you can tolerate getting huge file sizes. 41 | 42 | ### From PNG frames 43 | 44 | A directory full of PNG frames can be used as an input too. You can export them from any animation software. If you have `ffmpeg` installed, you can also export frames with it: 45 | 46 | ```sh 47 | ffmpeg -i video.webm frame%04d.png 48 | ``` 49 | 50 | and then make the GIF from the frames: 51 | 52 | ```sh 53 | gifski -o anim.gif frame*.png 54 | ``` 55 | 56 | Note that `*` is a special wildcard character, and it won't work when placed inside quoted string (`"*"`). 57 | 58 | You can also resize frames (with `-W ` option). If the input was ever encoded using a lossy video codec it's recommended to at least halve size of the frames to hide compression artefacts and counter chroma subsampling that was done by the video codec. 59 | 60 | See `gifski --help` for more options. 61 | 62 | ### Tips for smaller GIF files 63 | 64 | Expect to lose a lot of quality for little gain. GIF just isn't that good at compressing, no matter how much you compromise. 65 | 66 | * Use `--width` and `--height` to make the animation smaller. This makes the biggest difference. 67 | * Add `--quality=80` (or a lower number) to lower overall quality. You can fine-tune the quality with: 68 | * `--lossy-quality=60` lower values make animations noisier/grainy, but reduce file sizes. 69 | * `--motion-quality=60` lower values cause smearing or banding in frames with motion, but reduce file sizes. 70 | 71 | If you need to make a GIF that fits a predefined file size, you have to experiment with different sizes and quality settings. The command line tool will display estimated total file size during compression, but keep in mind that the estimate is very imprecise. 72 | 73 | ## Building 74 | 75 | 1. [Install Rust via rustup](https://www.rust-lang.org/en-US/install.html). This project only supports up-to-date versions of Rust. You may get errors about "unstable" features if your compiler version is too old. Run `rustup update`. 76 | 2. Clone the repository: `git clone https://github.com/ImageOptim/gifski` 77 | 3. In the cloned directory, run: `cargo build --release`. This will build in `./target/release`. 78 | 79 | ### Using from C 80 | 81 | [See `gifski.h`](https://github.com/ImageOptim/gifski/blob/main/gifski.h) for [the C API](https://docs.rs/gifski/latest/gifski/c_api/#functions). To build the library, run: 82 | 83 | ```sh 84 | rustup update 85 | cargo build --release 86 | ``` 87 | 88 | and link with `target/release/libgifski.a`. Please observe the [LICENSE](LICENSE). 89 | 90 | ### C dynamic library for package maintainers 91 | 92 | The build process uses [`cargo-c`](https://lib.rs/cargo-c) for building the dynamic library correctly and generating the pkg-config file. 93 | 94 | ```sh 95 | rustup update 96 | cargo install cargo-c 97 | # build 98 | cargo cbuild --prefix=/usr --release 99 | # install 100 | cargo cinstall --prefix=/usr --release --destdir=pkgroot 101 | ``` 102 | 103 | The `cbuild` command can be omitted, since `cinstall` will trigger a build if it hasn't been done already. 104 | 105 | ## License 106 | 107 | AGPL 3 or later. I can offer alternative licensing options, including [commercial licenses](https://supso.org/projects/pngquant). Let [me](https://kornel.ski/contact) know if you'd like to use it in a product incompatible with this license. 108 | 109 | ## With built-in video support 110 | 111 | The tool optionally supports decoding video directly, but unfortunately it relies on ffmpeg 6.x, which may be *very hard* to get working, so it's not enabled by default. 112 | 113 | You must have `ffmpeg` and `libclang` installed, both with their C headers installed in default system include paths. Details depend on the platform and version, but you usually need to install packages such as `libavformat-dev`, `libavfilter-dev`, `libavdevice-dev`, `libclang-dev`, `clang`. Please note that installation of these dependencies may be quite difficult. Especially on macOS and Windows it takes *expert knowledge* to just get them installed without wasting several hours on endless stupid installation and compilation errors, which I can't help with. If you're cross-compiling, try uncommenting `[patch.crates-io]` section at the end of `Cargo.toml`, which includes some experimental fixes for ffmpeg. 114 | 115 | Once you have dependencies installed, compile with `cargo build --release --features=video` or `cargo build --release --features=video-static`. 116 | 117 | When compiled with video support [ffmpeg licenses](https://www.ffmpeg.org/legal.html) apply. You may need to have a patent license to use H.264/H.265 video (I recommend using VP9/WebM instead). 118 | 119 | ```sh 120 | gifski -o out.gif video.mp4 121 | ``` 122 | 123 | ## Cross-compilation for iOS 124 | 125 | The easy option is to use the included `gifski.xcodeproj` file to build the library automatically for all Apple platforms. Add it as a [subproject](https://lib.rs/crates/cargo-xcode) to your Xcode project, and link with `gifski-staticlib` Xcode target. See [the GUI app](https://github.com/sindresorhus/Gifski) for an example how to integrate the library. 126 | 127 | ### Cross-compilation for iOS manually 128 | 129 | Make sure you have Rust installed via [rustup](https://rustup.rs/). Run once: 130 | 131 | ```sh 132 | rustup target add aarch64-apple-ios 133 | ``` 134 | 135 | and then to build the library: 136 | 137 | ```sh 138 | rustup update 139 | cargo build --lib --release --target=aarch64-apple-ios 140 | ``` 141 | 142 | The build may print "dropping unsupported crate type `cdylib`" warning. This is expected when building for iOS. 143 | 144 | This will create a static library in `./target/aarch64-apple-ios/release/libgifski.a`. You can add this library to your Xcode project. See [gifski.app](https://github.com/sindresorhus/Gifski) for an example how to use libgifski from Swift. 145 | 146 | -------------------------------------------------------------------------------- /gifski.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | 7 | #ifdef __cplusplus 8 | extern "C" { 9 | #endif 10 | 11 | struct gifski; 12 | typedef struct gifski gifski; 13 | 14 | /** 15 | How to use from C 16 | 17 | ```c 18 | gifski *g = gifski_new(&(GifskiSettings){ 19 | .quality = 90, 20 | }); 21 | gifski_set_file_output(g, "file.gif"); 22 | 23 | for(int i=0; i < frames; i++) { 24 | int res = gifski_add_frame_rgba(g, i, width, height, buffer, 5); 25 | if (res != GIFSKI_OK) break; 26 | } 27 | int res = gifski_finish(g); 28 | if (res != GIFSKI_OK) return; 29 | ``` 30 | 31 | It's safe and efficient to call `gifski_add_frame_*` in a loop as fast as you can get frames, 32 | because it blocks and waits until previous frames are written. 33 | 34 | To cancel processing, make progress callback return 0 and call `gifski_finish()`. The write callback 35 | may still be called between the cancellation and `gifski_finish()` returning. 36 | 37 | To build as a library: 38 | 39 | ```bash 40 | cargo build --release --lib 41 | ``` 42 | 43 | it will create `target/release/libgifski.a` (static library) 44 | and `target/release/libgifski.so`/`dylib` or `gifski.dll` (dynamic library) 45 | 46 | Static is recommended. 47 | 48 | To build for iOS: 49 | 50 | ```bash 51 | rustup target add aarch64-apple-ios 52 | cargo build --release --lib --target aarch64-apple-ios 53 | ``` 54 | 55 | it will build `target/aarch64-apple-ios/release/libgifski.a` (ignore the warning about cdylib). 56 | 57 | */ 58 | 59 | /** 60 | * Settings for creating a new encoder instance. See `gifski_new` 61 | */ 62 | typedef struct GifskiSettings { 63 | /** 64 | * Resize to max this width if non-0. 65 | */ 66 | uint32_t width; 67 | /** 68 | * Resize to max this height if width is non-0. Note that aspect ratio is not preserved. 69 | */ 70 | uint32_t height; 71 | /** 72 | * 1-100, but useful range is 50-100. Recommended to set to 90. 73 | */ 74 | uint8_t quality; 75 | /** 76 | * Lower quality, but faster encode. 77 | */ 78 | bool fast; 79 | /** 80 | * If negative, looping is disabled. The number of times the sequence is repeated. 0 to loop forever. 81 | */ 82 | int16_t repeat; 83 | } GifskiSettings; 84 | 85 | enum GifskiError { 86 | GIFSKI_OK = 0, 87 | /** one of input arguments was NULL */ 88 | GIFSKI_NULL_ARG, 89 | /** a one-time function was called twice, or functions were called in wrong order */ 90 | GIFSKI_INVALID_STATE, 91 | /** internal error related to palette quantization */ 92 | GIFSKI_QUANT, 93 | /** internal error related to gif composing */ 94 | GIFSKI_GIF, 95 | /** internal error - unexpectedly aborted */ 96 | GIFSKI_THREAD_LOST, 97 | /** I/O error: file or directory not found */ 98 | GIFSKI_NOT_FOUND, 99 | /** I/O error: permission denied */ 100 | GIFSKI_PERMISSION_DENIED, 101 | /** I/O error: file already exists */ 102 | GIFSKI_ALREADY_EXISTS, 103 | /** invalid arguments passed to function */ 104 | GIFSKI_INVALID_INPUT, 105 | /** misc I/O error */ 106 | GIFSKI_TIMED_OUT, 107 | /** misc I/O error */ 108 | GIFSKI_WRITE_ZERO, 109 | /** misc I/O error */ 110 | GIFSKI_INTERRUPTED, 111 | /** misc I/O error */ 112 | GIFSKI_UNEXPECTED_EOF, 113 | /** progress callback returned 0, writing aborted */ 114 | GIFSKI_ABORTED, 115 | /** should not happen, file a bug */ 116 | GIFSKI_OTHER, 117 | }; 118 | 119 | /* workaround for a wrong definition in an older version of this header. Please use GIFSKI_ABORTED directly */ 120 | #ifndef ABORTED 121 | #define ABORTED GIFSKI_ABORTED 122 | #endif 123 | 124 | typedef enum GifskiError GifskiError; 125 | 126 | /** 127 | * Call to start the process 128 | * 129 | * See `gifski_add_frame_png_file` and `gifski_end_adding_frames` 130 | * 131 | * Returns a handle for the other functions, or `NULL` on error (if the settings are invalid). 132 | */ 133 | gifski *gifski_new(const GifskiSettings *settings); 134 | 135 | 136 | /** Quality 1-100 of temporal denoising. Lower values reduce motion. Defaults to `settings.quality`. 137 | * 138 | * Only valid immediately after calling `gifski_new`, before any frames are added. */ 139 | GifskiError gifski_set_motion_quality(gifski *handle, uint8_t quality); 140 | 141 | /** Quality 1-100 of gifsicle compression. Lower values add noise. Defaults to `settings.quality`. 142 | * Has no effect if the `gifsicle` feature hasn't been enabled. 143 | * Only valid immediately after calling `gifski_new`, before any frames are added. */ 144 | GifskiError gifski_set_lossy_quality(gifski *handle, uint8_t quality); 145 | 146 | /** If `true`, encoding will be significantly slower, but may look a bit better. 147 | * 148 | * Only valid immediately after calling `gifski_new`, before any frames are added. */ 149 | GifskiError gifski_set_extra_effort(gifski *handle, bool extra); 150 | 151 | /** 152 | * Adds a frame to the animation. This function is asynchronous. 153 | * 154 | * File path must be valid UTF-8. 155 | * 156 | * `frame_number` orders frames (consecutive numbers starting from 0). 157 | * You can add frames in any order, and they will be sorted by their `frame_number`. 158 | * 159 | * Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed. 160 | * For a 20fps video it could be `frame_number/20.0`. 161 | * Frames with duplicate or out-of-order PTS will be skipped. 162 | * 163 | * The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. 164 | * 165 | * This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. 166 | * 167 | * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 168 | */ 169 | GifskiError gifski_add_frame_png_file(gifski *handle, 170 | uint32_t frame_number, 171 | const char *file_path, 172 | double presentation_timestamp); 173 | 174 | /** 175 | * Adds a frame to the animation. This function is asynchronous. 176 | * 177 | * `pixels` is an array width×height×4 bytes large. 178 | * The array is copied, so you can free/reuse it immediately after this function returns. 179 | * 180 | * `frame_number` orders frames (consecutive numbers starting from 0). 181 | * You can add frames in any order, and they will be sorted by their `frame_number`. 182 | * However, out-of-order frames are buffered in RAM, and will cause high memory usage 183 | * if there are gaps in the frame numbers. 184 | * 185 | * Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed. 186 | * For a 20fps video it could be `frame_number/20.0`. First frame must have PTS=0. 187 | * Frames with duplicate or out-of-order PTS will be skipped. 188 | * 189 | * The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. 190 | * 191 | * Colors are in sRGB, uncorrelated RGBA, with alpha byte last. 192 | * 193 | * This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. 194 | * 195 | * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 196 | */ 197 | GifskiError gifski_add_frame_rgba(gifski *handle, 198 | uint32_t frame_number, 199 | uint32_t width, 200 | uint32_t height, 201 | const unsigned char *pixels, 202 | double presentation_timestamp); 203 | 204 | /** Same as `gifski_add_frame_rgba`, but with bytes per row arg */ 205 | GifskiError gifski_add_frame_rgba_stride(gifski *handle, 206 | uint32_t frame_number, 207 | uint32_t width, 208 | uint32_t height, 209 | uint32_t bytes_per_row, 210 | const unsigned char *pixels, 211 | double presentation_timestamp); 212 | 213 | /** Same as `gifski_add_frame_rgba_stride`, except it expects components in ARGB order. 214 | 215 | Bytes per row must be multiple of 4, and greater or equal width×4. 216 | If the bytes per row value is invalid (e.g. an odd number), frames may look sheared/skewed. 217 | 218 | Colors are in sRGB, uncorrelated ARGB, with alpha byte first. 219 | 220 | `gifski_add_frame_rgba` is preferred over this function. 221 | */ 222 | GifskiError gifski_add_frame_argb(gifski *handle, 223 | uint32_t frame_number, 224 | uint32_t width, 225 | uint32_t bytes_per_row, 226 | uint32_t height, 227 | const unsigned char *pixels, 228 | double presentation_timestamp); 229 | 230 | /** Same as `gifski_add_frame_rgba_stride`, except it expects RGB components (3 bytes per pixel) 231 | 232 | Bytes per row must be multiple of 3, and greater or equal width×3. 233 | If the bytes per row value is invalid (not multiple of 3), frames may look sheared/skewed. 234 | 235 | Colors are in sRGB, red byte first. 236 | 237 | `gifski_add_frame_rgba` is preferred over this function. 238 | */ 239 | GifskiError gifski_add_frame_rgb(gifski *handle, 240 | uint32_t frame_number, 241 | uint32_t width, 242 | uint32_t bytes_per_row, 243 | uint32_t height, 244 | const unsigned char *pixels, 245 | double presentation_timestamp); 246 | 247 | /** 248 | * Get a callback for frame processed, and abort processing if desired. 249 | * 250 | * The callback is called once per input frame, 251 | * even if the encoder decides to skip some frames. 252 | * 253 | * It gets arbitrary pointer (`user_data`) as an argument. `user_data` can be `NULL`. 254 | * 255 | * The callback must return `1` to continue processing, or `0` to abort. 256 | * 257 | * The callback must be thread-safe (it will be called from another thread). 258 | * It must remain valid at all times, until `gifski_finish` completes. 259 | * 260 | * This function must be called before `gifski_set_file_output()` to take effect. 261 | */ 262 | void gifski_set_progress_callback(gifski *handle, int (*progress_callback)(void *user_data), void *user_data); 263 | 264 | /** 265 | * Get a callback when an error occurs. 266 | * This is intended mostly for logging and debugging, not for user interface. 267 | * 268 | * The callback function has the following arguments: 269 | * * A `\0`-terminated C string in UTF-8 encoding. The string is only valid for the duration of the call. Make a copy if you need to keep it. 270 | * * An arbitrary pointer (`user_data`). `user_data` can be `NULL`. 271 | * 272 | * The callback must be thread-safe (it will be called from another thread). 273 | * It must remain valid at all times, until `gifski_finish` completes. 274 | * 275 | * If the callback is not set, errors will be printed to stderr. 276 | * 277 | * This function must be called before `gifski_set_file_output()` to take effect. 278 | */ 279 | GifskiError gifski_set_error_message_callback(gifski *handle, void (*error_message_callback)(const char*, void*), void *user_data); 280 | 281 | /** 282 | * Start writing to the file at `destination_path` (overwrites if needed). 283 | * The file path must be ASCII or valid UTF-8. 284 | * 285 | * This function has to be called before any frames are added. 286 | * This call will not block. 287 | * 288 | * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 289 | */ 290 | GifskiError gifski_set_file_output(gifski *handle, const char *destination_path); 291 | 292 | /** 293 | * Start writing via callback (any buffer, file, whatever you want). This has to be called before any frames are added. 294 | * This call will not block. 295 | * 296 | * The callback function receives 3 arguments: 297 | * - size of the buffer to write, in bytes. IT MAY BE ZERO (when it's zero, either do nothing, or flush internal buffers if necessary). 298 | * - pointer to the buffer. 299 | * - context pointer to arbitrary user data, same as passed in to this function. 300 | * 301 | * The callback should return 0 (`GIFSKI_OK`) on success, and non-zero on error. 302 | * 303 | * The callback function must be thread-safe. It must remain valid at all times, until `gifski_finish` completes. 304 | * 305 | * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 306 | */ 307 | GifskiError gifski_set_write_callback(gifski *handle, 308 | int (*write_callback)(size_t buffer_length, const uint8_t *buffer, void *user_data), 309 | void *user_data); 310 | 311 | /** 312 | * The last step: 313 | * - stops accepting any more frames (gifski_add_frame_* calls are blocked) 314 | * - blocks and waits until all already-added frames have finished writing 315 | * 316 | * Returns final status of write operations. Remember to check the return value! 317 | * 318 | * Must always be called, otherwise it will leak memory. 319 | * After this call, the handle is freed and can't be used any more. 320 | * 321 | * Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 322 | */ 323 | GifskiError gifski_finish(gifski *g); 324 | 325 | #ifdef __cplusplus 326 | } 327 | #endif 328 | -------------------------------------------------------------------------------- /gifski.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | /* generated with cargo-xcode 1.11.0 */ 4 | archiveVersion = 1; 5 | classes = { 6 | }; 7 | objectVersion = 53; 8 | objects = { 9 | 10 | /* Begin PBXBuildFile section */ 11 | CA00E74C7D4159EA34BF617B /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--lib"; }; }; 12 | CA01E74C7D41A82EB53EFF50 /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--lib"; }; }; 13 | CA02E74C7D4162D760BFA4D3 /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9AE29BDC33EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--bin 'gifski' --features 'binary'"; }; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXBuildRule section */ 17 | CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */ = { 18 | isa = PBXBuildRule; 19 | compilerSpec = com.apple.compilers.proxy.script; 20 | dependencyFile = "$(DERIVED_FILE_DIR)/$(ARCHS)-$(EXECUTABLE_NAME).d"; 21 | filePatterns = "*/Cargo.toml"; 22 | fileType = pattern.proxy; 23 | inputFiles = ( 24 | ); 25 | isEditable = 0; 26 | name = "Cargo project build"; 27 | outputFiles = ( 28 | "$(TARGET_BUILD_DIR)/$(EXECUTABLE_NAME)", 29 | ); 30 | runOncePerArchitecture = 0; 31 | script = "# generated with cargo-xcode 1.11.0\nset -euo pipefail;\nexport PATH=\"$HOME/.cargo/bin:$PATH:/usr/local/bin:/opt/homebrew/bin\";\n# don't use ios/watchos linker for build scripts and proc macros\nexport CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/ld\nexport CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=/usr/bin/ld\nexport NO_COLOR=1\n\ncase \"$PLATFORM_NAME\" in\n \"macosx\")\n CARGO_XCODE_TARGET_OS=darwin\n if [ \"${IS_MACCATALYST-NO}\" = YES ]; then\n CARGO_XCODE_TARGET_OS=ios-macabi\n fi\n ;;\n \"iphoneos\") CARGO_XCODE_TARGET_OS=ios ;;\n \"iphonesimulator\") CARGO_XCODE_TARGET_OS=ios-sim ;;\n \"appletvos\" | \"appletvsimulator\") CARGO_XCODE_TARGET_OS=tvos ;;\n \"watchos\") CARGO_XCODE_TARGET_OS=watchos ;;\n \"watchsimulator\") CARGO_XCODE_TARGET_OS=watchos-sim ;;\n \"xros\") CARGO_XCODE_TARGET_OS=visionos ;;\n \"xrsimulator\") CARGO_XCODE_TARGET_OS=visionos-sim ;;\n *)\n CARGO_XCODE_TARGET_OS=\"$PLATFORM_NAME\"\n echo >&2 \"warning: cargo-xcode needs to be updated to handle $PLATFORM_NAME\"\n ;;\nesac\n\nCARGO_XCODE_TARGET_TRIPLES=\"\"\nCARGO_XCODE_TARGET_FLAGS=\"\"\nLIPO_ARGS=\"\"\nfor arch in $ARCHS; do\n if [[ \"$arch\" == \"arm64\" ]]; then arch=aarch64; fi\n if [[ \"$arch\" == \"i386\" && \"$CARGO_XCODE_TARGET_OS\" != \"ios\" ]]; then arch=i686; fi\n triple=\"${arch}-apple-$CARGO_XCODE_TARGET_OS\"\n CARGO_XCODE_TARGET_TRIPLES+=\" $triple\"\n CARGO_XCODE_TARGET_FLAGS+=\" --target=$triple\"\n LIPO_ARGS+=\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME\n\"\ndone\n\necho >&2 \"Cargo $CARGO_XCODE_BUILD_PROFILE $ACTION for $PLATFORM_NAME $ARCHS =$CARGO_XCODE_TARGET_TRIPLES; using ${SDK_NAMES:-}. \\$PATH is:\"\ntr >&2 : '\\n' <<<\"$PATH\"\n\nif command -v rustup &> /dev/null; then\n for triple in $CARGO_XCODE_TARGET_TRIPLES; do\n if ! rustup target list --installed | grep -Eq \"^$triple$\"; then\n echo >&2 \"warning: this build requires rustup toolchain for $triple, but it isn't installed (will try rustup next)\"\n rustup target add \"$triple\" || {\n echo >&2 \"warning: can't install $triple, will try nightly -Zbuild-std\";\n OTHER_INPUT_FILE_FLAGS+=\" -Zbuild-std\";\n if [ -z \"${RUSTUP_TOOLCHAIN:-}\" ]; then\n export RUSTUP_TOOLCHAIN=nightly\n fi\n break;\n }\n fi\n done\nfi\n\nif [ \"$CARGO_XCODE_BUILD_PROFILE\" = release ]; then\n OTHER_INPUT_FILE_FLAGS=\"$OTHER_INPUT_FILE_FLAGS --release\"\nfi\n\nif [ \"$ACTION\" = clean ]; then\n cargo clean --verbose --manifest-path=\"$SCRIPT_INPUT_FILE\" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS;\n rm -f \"$SCRIPT_OUTPUT_FILE_0\"\n exit 0\nfi\n\n{ cargo build --manifest-path=\"$SCRIPT_INPUT_FILE\" --features=\"${CARGO_XCODE_FEATURES:-}\" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS --verbose --message-format=short 2>&1 | sed -E 's/^([^ :]+:[0-9]+:[0-9]+: error)/\\1: /' >&2; } || { echo >&2 \"$SCRIPT_INPUT_FILE: error: cargo-xcode project build failed; $CARGO_XCODE_TARGET_TRIPLES\"; exit 1; }\n\ntr '\\n' '\\0' <<<\"$LIPO_ARGS\" | xargs -0 lipo -create -output \"$SCRIPT_OUTPUT_FILE_0\"\n\nif [ ${LD_DYLIB_INSTALL_NAME:+1} ]; then\n install_name_tool -id \"$LD_DYLIB_INSTALL_NAME\" \"$SCRIPT_OUTPUT_FILE_0\"\nfi\n\nDEP_FILE_DST=\"$DERIVED_FILE_DIR/${ARCHS}-${EXECUTABLE_NAME}.d\"\necho \"\" > \"$DEP_FILE_DST\"\nfor triple in $CARGO_XCODE_TARGET_TRIPLES; do\n BUILT_SRC=\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME\"\n\n # cargo generates a dep file, but for its own path, so append our rename to it\n DEP_FILE_SRC=\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_DEP_FILE_NAME\"\n if [ -f \"$DEP_FILE_SRC\" ]; then\n cat \"$DEP_FILE_SRC\" >> \"$DEP_FILE_DST\"\n fi\n echo >> \"$DEP_FILE_DST\" \"${SCRIPT_OUTPUT_FILE_0/ /\\\\ /}: ${BUILT_SRC/ /\\\\ /}\"\ndone\ncat \"$DEP_FILE_DST\"\n\necho \"success: $ACTION of $SCRIPT_OUTPUT_FILE_0 for $CARGO_XCODE_TARGET_TRIPLES\"\n"; 32 | }; 33 | /* End PBXBuildRule section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | CA007E4815895A689885C260 /* libgifski_static.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libgifski_static.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37 | CA013DB14D7B8559E8DD8BDF /* gifski.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = gifski.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 38 | CA026E6D6F94D179B4D3744F /* gifski */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = gifski; sourceTree = BUILT_PRODUCTS_DIR; }; 39 | CAF9AE29BDC33EF4668187A5 /* Cargo.toml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = Cargo.toml; path = Cargo.toml; sourceTree = ""; }; 40 | /* End PBXFileReference section */ 41 | 42 | /* Begin PBXGroup section */ 43 | CAF0AE29BDC3D65BC3C892A8 = { 44 | isa = PBXGroup; 45 | children = ( 46 | CAF9AE29BDC33EF4668187A5 /* Cargo.toml */, 47 | CAF1AE29BDC322869D176AE5 /* Products */, 48 | CAF2AE29BDC398AF0B5890DB /* Frameworks */, 49 | ); 50 | sourceTree = ""; 51 | }; 52 | CAF1AE29BDC322869D176AE5 /* Products */ = { 53 | isa = PBXGroup; 54 | children = ( 55 | CA007E4815895A689885C260 /* libgifski_static.a */, 56 | CA013DB14D7B8559E8DD8BDF /* gifski.dylib */, 57 | CA026E6D6F94D179B4D3744F /* gifski */, 58 | ); 59 | name = Products; 60 | sourceTree = ""; 61 | }; 62 | CAF2AE29BDC398AF0B5890DB /* Frameworks */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | ); 66 | name = Frameworks; 67 | sourceTree = ""; 68 | }; 69 | /* End PBXGroup section */ 70 | 71 | /* Begin PBXNativeTarget section */ 72 | CA007E48158959EA34BF617B /* gifski.a (static library) */ = { 73 | isa = PBXNativeTarget; 74 | buildConfigurationList = CA007084E6B259EA34BF617B /* Build configuration list for PBXNativeTarget "gifski.a (static library)" */; 75 | buildPhases = ( 76 | CA00A0D466D559EA34BF617B /* Sources */, 77 | ); 78 | buildRules = ( 79 | CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */, 80 | ); 81 | dependencies = ( 82 | ); 83 | name = "gifski.a (static library)"; 84 | productName = libgifski_static.a; 85 | productReference = CA007E4815895A689885C260 /* libgifski_static.a */; 86 | productType = "com.apple.product-type.library.static"; 87 | }; 88 | CA013DB14D7BA82EB53EFF50 /* gifski.dylib (cdylib) */ = { 89 | isa = PBXNativeTarget; 90 | buildConfigurationList = CA017084E6B2A82EB53EFF50 /* Build configuration list for PBXNativeTarget "gifski.dylib (cdylib)" */; 91 | buildPhases = ( 92 | CA01A0D466D5A82EB53EFF50 /* Sources */, 93 | ); 94 | buildRules = ( 95 | CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */, 96 | ); 97 | dependencies = ( 98 | ); 99 | name = "gifski.dylib (cdylib)"; 100 | productName = gifski.dylib; 101 | productReference = CA013DB14D7B8559E8DD8BDF /* gifski.dylib */; 102 | productType = "com.apple.product-type.library.dynamic"; 103 | }; 104 | CA026E6D6F9462D760BFA4D3 /* gifski (standalone executable) */ = { 105 | isa = PBXNativeTarget; 106 | buildConfigurationList = CA027084E6B262D760BFA4D3 /* Build configuration list for PBXNativeTarget "gifski (standalone executable)" */; 107 | buildPhases = ( 108 | CA02A0D466D562D760BFA4D3 /* Sources */, 109 | ); 110 | buildRules = ( 111 | CAF4AE29BDC3AC6C1400ACA8 /* PBXBuildRule */, 112 | ); 113 | dependencies = ( 114 | ); 115 | name = "gifski (standalone executable)"; 116 | productName = gifski; 117 | productReference = CA026E6D6F94D179B4D3744F /* gifski */; 118 | productType = "com.apple.product-type.tool"; 119 | }; 120 | /* End PBXNativeTarget section */ 121 | 122 | /* Begin PBXProject section */ 123 | CAF3AE29BDC3E04653AD465F /* Project object */ = { 124 | isa = PBXProject; 125 | attributes = { 126 | BuildIndependentTargetsInParallel = YES; 127 | LastUpgradeCheck = 1510; 128 | TargetAttributes = { 129 | CA007E48158959EA34BF617B = { 130 | CreatedOnToolsVersion = 9.2; 131 | ProvisioningStyle = Automatic; 132 | }; 133 | CA013DB14D7BA82EB53EFF50 = { 134 | CreatedOnToolsVersion = 9.2; 135 | ProvisioningStyle = Automatic; 136 | }; 137 | CA026E6D6F9462D760BFA4D3 = { 138 | CreatedOnToolsVersion = 9.2; 139 | ProvisioningStyle = Automatic; 140 | }; 141 | }; 142 | }; 143 | buildConfigurationList = CAF6AE29BDC380E02D6C7F57 /* Build configuration list for PBXProject "gifski" */; 144 | compatibilityVersion = "Xcode 11.4"; 145 | developmentRegion = en; 146 | hasScannedForEncodings = 0; 147 | knownRegions = ( 148 | en, 149 | Base, 150 | ); 151 | mainGroup = CAF0AE29BDC3D65BC3C892A8; 152 | productRefGroup = CAF1AE29BDC322869D176AE5 /* Products */; 153 | projectDirPath = ""; 154 | projectRoot = ""; 155 | targets = ( 156 | CA007E48158959EA34BF617B /* gifski.a (static library) */, 157 | CA013DB14D7BA82EB53EFF50 /* gifski.dylib (cdylib) */, 158 | CA026E6D6F9462D760BFA4D3 /* gifski (standalone executable) */, 159 | ); 160 | }; 161 | /* End PBXProject section */ 162 | 163 | /* Begin PBXSourcesBuildPhase section */ 164 | CA00A0D466D559EA34BF617B /* Sources */ = { 165 | isa = PBXSourcesBuildPhase; 166 | buildActionMask = 2147483647; 167 | files = ( 168 | CA00E74C7D4159EA34BF617B /* Cargo.toml in Sources */, 169 | ); 170 | runOnlyForDeploymentPostprocessing = 0; 171 | }; 172 | CA01A0D466D5A82EB53EFF50 /* Sources */ = { 173 | isa = PBXSourcesBuildPhase; 174 | buildActionMask = 2147483647; 175 | files = ( 176 | CA01E74C7D41A82EB53EFF50 /* Cargo.toml in Sources */, 177 | ); 178 | runOnlyForDeploymentPostprocessing = 0; 179 | }; 180 | CA02A0D466D562D760BFA4D3 /* Sources */ = { 181 | isa = PBXSourcesBuildPhase; 182 | buildActionMask = 2147483647; 183 | files = ( 184 | CA02E74C7D4162D760BFA4D3 /* Cargo.toml in Sources */, 185 | ); 186 | runOnlyForDeploymentPostprocessing = 0; 187 | }; 188 | /* End PBXSourcesBuildPhase section */ 189 | 190 | /* Begin XCBuildConfiguration section */ 191 | CA009A4E111D59EA34BF617B /* Release */ = { 192 | isa = XCBuildConfiguration; 193 | buildSettings = { 194 | CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; 195 | CARGO_XCODE_CARGO_FILE_NAME = libgifski.a; 196 | INSTALL_GROUP = ""; 197 | INSTALL_MODE_FLAG = ""; 198 | INSTALL_OWNER = ""; 199 | PRODUCT_NAME = gifski_static; 200 | SKIP_INSTALL = YES; 201 | SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; 202 | }; 203 | name = Release; 204 | }; 205 | CA008F2BE1C459EA34BF617B /* Debug */ = { 206 | isa = XCBuildConfiguration; 207 | buildSettings = { 208 | CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; 209 | CARGO_XCODE_CARGO_FILE_NAME = libgifski.a; 210 | INSTALL_GROUP = ""; 211 | INSTALL_MODE_FLAG = ""; 212 | INSTALL_OWNER = ""; 213 | PRODUCT_NAME = gifski_static; 214 | SKIP_INSTALL = YES; 215 | SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; 216 | }; 217 | name = Debug; 218 | }; 219 | CA019A4E111DA82EB53EFF50 /* Release */ = { 220 | isa = XCBuildConfiguration; 221 | buildSettings = { 222 | CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; 223 | CARGO_XCODE_CARGO_FILE_NAME = libgifski.dylib; 224 | PRODUCT_NAME = gifski; 225 | SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos"; 226 | }; 227 | name = Release; 228 | }; 229 | CA018F2BE1C4A82EB53EFF50 /* Debug */ = { 230 | isa = XCBuildConfiguration; 231 | buildSettings = { 232 | CARGO_XCODE_CARGO_DEP_FILE_NAME = libgifski.d; 233 | CARGO_XCODE_CARGO_FILE_NAME = libgifski.dylib; 234 | PRODUCT_NAME = gifski; 235 | SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos"; 236 | }; 237 | name = Debug; 238 | }; 239 | CA029A4E111D62D760BFA4D3 /* Release */ = { 240 | isa = XCBuildConfiguration; 241 | buildSettings = { 242 | CARGO_XCODE_CARGO_DEP_FILE_NAME = gifski.d; 243 | CARGO_XCODE_CARGO_FILE_NAME = gifski; 244 | PRODUCT_NAME = gifski; 245 | SUPPORTED_PLATFORMS = macosx; 246 | }; 247 | name = Release; 248 | }; 249 | CA028F2BE1C462D760BFA4D3 /* Debug */ = { 250 | isa = XCBuildConfiguration; 251 | buildSettings = { 252 | CARGO_XCODE_CARGO_DEP_FILE_NAME = gifski.d; 253 | CARGO_XCODE_CARGO_FILE_NAME = gifski; 254 | PRODUCT_NAME = gifski; 255 | SUPPORTED_PLATFORMS = macosx; 256 | }; 257 | name = Debug; 258 | }; 259 | CAF7D702CA573CC16B37690B /* Release */ = { 260 | isa = XCBuildConfiguration; 261 | buildSettings = { 262 | "ADDITIONAL_SDKS[sdk=i*]" = macosx; 263 | "ADDITIONAL_SDKS[sdk=w*]" = macosx; 264 | "ADDITIONAL_SDKS[sdk=x*]" = macosx; 265 | "ADDITIONAL_SDKS[sdk=a*]" = macosx; 266 | ALWAYS_SEARCH_USER_PATHS = NO; 267 | CARGO_TARGET_DIR = "$(PROJECT_TEMP_DIR)/cargo_target"; 268 | CARGO_XCODE_BUILD_PROFILE = release; 269 | CARGO_XCODE_FEATURES = ""; 270 | CURRENT_PROJECT_VERSION = 1.32; 271 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 272 | MARKETING_VERSION = 1.32.1; 273 | PRODUCT_NAME = gifski; 274 | RUSTUP_TOOLCHAIN = ""; 275 | SDKROOT = macosx; 276 | SUPPORTS_MACCATALYST = YES; 277 | }; 278 | name = Release; 279 | }; 280 | CAF8D702CA57228BE02872F8 /* Debug */ = { 281 | isa = XCBuildConfiguration; 282 | buildSettings = { 283 | "ADDITIONAL_SDKS[sdk=i*]" = macosx; 284 | "ADDITIONAL_SDKS[sdk=w*]" = macosx; 285 | "ADDITIONAL_SDKS[sdk=x*]" = macosx; 286 | "ADDITIONAL_SDKS[sdk=a*]" = macosx; 287 | ALWAYS_SEARCH_USER_PATHS = NO; 288 | CARGO_TARGET_DIR = "$(PROJECT_TEMP_DIR)/cargo_target"; 289 | CARGO_XCODE_BUILD_PROFILE = debug; 290 | CARGO_XCODE_FEATURES = ""; 291 | CURRENT_PROJECT_VERSION = 1.32; 292 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 293 | MARKETING_VERSION = 1.32.1; 294 | ONLY_ACTIVE_ARCH = YES; 295 | PRODUCT_NAME = gifski; 296 | RUSTUP_TOOLCHAIN = ""; 297 | SDKROOT = macosx; 298 | SUPPORTS_MACCATALYST = YES; 299 | }; 300 | name = Debug; 301 | }; 302 | /* End XCBuildConfiguration section */ 303 | 304 | /* Begin XCConfigurationList section */ 305 | CA007084E6B259EA34BF617B /* Build configuration list for PBXNativeTarget "gifski.a (static library)" */ = { 306 | isa = XCConfigurationList; 307 | buildConfigurations = ( 308 | CA009A4E111D59EA34BF617B /* Release */, 309 | CA008F2BE1C459EA34BF617B /* Debug */, 310 | ); 311 | defaultConfigurationIsVisible = 0; 312 | defaultConfigurationName = Release; 313 | }; 314 | CA017084E6B2A82EB53EFF50 /* Build configuration list for PBXNativeTarget "gifski.dylib (cdylib)" */ = { 315 | isa = XCConfigurationList; 316 | buildConfigurations = ( 317 | CA019A4E111DA82EB53EFF50 /* Release */, 318 | CA018F2BE1C4A82EB53EFF50 /* Debug */, 319 | ); 320 | defaultConfigurationIsVisible = 0; 321 | defaultConfigurationName = Release; 322 | }; 323 | CA027084E6B262D760BFA4D3 /* Build configuration list for PBXNativeTarget "gifski (standalone executable)" */ = { 324 | isa = XCConfigurationList; 325 | buildConfigurations = ( 326 | CA029A4E111D62D760BFA4D3 /* Release */, 327 | CA028F2BE1C462D760BFA4D3 /* Debug */, 328 | ); 329 | defaultConfigurationIsVisible = 0; 330 | defaultConfigurationName = Release; 331 | }; 332 | CAF6AE29BDC380E02D6C7F57 /* Build configuration list for PBXProject "gifski" */ = { 333 | isa = XCConfigurationList; 334 | buildConfigurations = ( 335 | CAF7D702CA573CC16B37690B /* Release */, 336 | CAF8D702CA57228BE02872F8 /* Debug */, 337 | ); 338 | defaultConfigurationIsVisible = 0; 339 | defaultConfigurationName = Release; 340 | }; 341 | /* End XCConfigurationList section */ 342 | }; 343 | rootObject = CAF3AE29BDC3E04653AD465F /* Project object */; 344 | } 345 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: gifski 2 | summary: gifski 3 | description: | 4 | GIF encoder based on libimagequant (pngquant). 5 | Squeezes maximum possible quality from the awful 6 | GIF format. https://gif.ski 7 | 8 | version: git 9 | 10 | grade: stable 11 | base: core22 12 | confinement: strict 13 | 14 | apps: 15 | gifski: 16 | command: bin/gifski 17 | plugs: 18 | - home 19 | - removable-media 20 | 21 | parts: 22 | gifski: 23 | source: https://github.com/ImageOptim/gifski.git 24 | plugin: rust 25 | rust-features: 26 | - video 27 | build-packages: 28 | - pkg-config 29 | - ffmpeg 30 | - libavcodec-dev 31 | - libavdevice-dev 32 | - libavfilter-dev 33 | - libavformat-dev 34 | - libavutil-dev 35 | - libswresample-dev 36 | - libswscale-dev 37 | - libclang-15-dev 38 | stage-packages: 39 | - ffmpeg 40 | - freeglut3 # dep leak from one of ffmpeg dev libs? 41 | - libglu1-mesa 42 | -------------------------------------------------------------------------------- /src/bin/ffmpeg_source.rs: -------------------------------------------------------------------------------- 1 | use crate::source::{Fps, Source}; 2 | use crate::{BinResult, SrcPath}; 3 | use gifski::{Collector, Settings}; 4 | use imgref::*; 5 | use rgb::*; 6 | 7 | pub struct FfmpegDecoder { 8 | input_context: ffmpeg::format::context::Input, 9 | frames: u64, 10 | rate: Fps, 11 | settings: Settings, 12 | } 13 | 14 | impl Source for FfmpegDecoder { 15 | fn total_frames(&self) -> Option { 16 | Some(self.frames) 17 | } 18 | 19 | fn collect(&mut self, dest: &mut Collector) -> BinResult<()> { 20 | self.collect_frames(dest) 21 | } 22 | } 23 | 24 | impl FfmpegDecoder { 25 | pub fn new(src: SrcPath, rate: Fps, settings: Settings) -> BinResult { 26 | ffmpeg::init().map_err(|e| format!("Unable to initialize ffmpeg: {}", e))?; 27 | let input_context = match src { 28 | SrcPath::Path(path) => ffmpeg::format::input(&path) 29 | .map_err(|e| format!("Unable to open video file {}: {}", path.display(), e))?, 30 | SrcPath::Stdin(_) => return Err("Video files must be specified as a path on disk. Input via stdin is not supported".into()), 31 | }; 32 | 33 | // take fps override into account 34 | let filter_fps = rate.fps / rate.speed; 35 | let stream = input_context.streams().best(ffmpeg::media::Type::Video).ok_or("The file has no video tracks")?; 36 | let time_base = stream.time_base().numerator() as f64 / stream.time_base().denominator() as f64; 37 | let frames = (stream.duration() as f64 * time_base * filter_fps as f64).ceil() as u64; 38 | Ok(Self { input_context, frames, rate, settings }) 39 | } 40 | 41 | #[inline(never)] 42 | pub fn collect_frames(&mut self, dest: &mut Collector) -> BinResult<()> { 43 | let (stream_index, mut decoder, mut filter) = { 44 | let filter_fps = self.rate.fps / self.rate.speed; 45 | let stream = self.input_context.streams().best(ffmpeg::media::Type::Video).ok_or("The file has no video tracks")?; 46 | 47 | let mut codec_context = ffmpeg::codec::context::Context::new(); 48 | codec_context.set_parameters(stream.parameters())?; 49 | let decoder = codec_context.decoder().video().map_err(|e| format!("Unable to decode the codec used in the video: {}", e))?; 50 | 51 | let (dest_width, dest_height) = self.settings.dimensions_for_image(decoder.width() as _, decoder.height() as _); 52 | 53 | let buffer_args = format!("width={}:height={}:video_size={}x{}:pix_fmt={}:time_base={}:sar={}", 54 | dest_width, 55 | dest_height, 56 | decoder.width(), 57 | decoder.height(), 58 | decoder.format().descriptor().ok_or("ffmpeg format error")?.name(), 59 | stream.time_base(), 60 | (|sar: ffmpeg::util::rational::Rational| match sar.numerator() { 61 | 0 => "1".to_string(), 62 | _ => format!("{}/{}", sar.numerator(), sar.denominator()), 63 | })(decoder.aspect_ratio()), 64 | ); 65 | let mut filter = ffmpeg::filter::Graph::new(); 66 | filter.add(&ffmpeg::filter::find("buffer").ok_or("ffmpeg format error")?, "in", &buffer_args)?; 67 | filter.add(&ffmpeg::filter::find("buffersink").ok_or("ffmpeg format error")?, "out", "")?; 68 | filter.output("in", 0)?.input("out", 0)?.parse(&format!("fps=fps={},format=rgba", filter_fps))?; 69 | filter.validate()?; 70 | (stream.index(), decoder, filter) 71 | }; 72 | 73 | let add_frame = |rgba_frame: &ffmpeg::util::frame::Video, pts: f64, pos: i64| -> BinResult<()> { 74 | let stride = rgba_frame.stride(0) as usize; 75 | if stride % 4 != 0 { 76 | Err("incompatible video")?; 77 | } 78 | let rgba_frame = ImgVec::new_stride( 79 | rgba_frame.data(0).as_rgba().to_owned(), 80 | rgba_frame.width() as usize, 81 | rgba_frame.height() as usize, 82 | stride / 4, 83 | ); 84 | Ok(dest.add_frame_rgba(pos as usize, rgba_frame, pts)?) 85 | }; 86 | 87 | let mut vid_frame = ffmpeg::util::frame::Video::empty(); 88 | let mut filt_frame = ffmpeg::util::frame::Video::empty(); 89 | let mut i = 0; 90 | let mut pts_last_packet = 0; 91 | let pts_frame_step = 1.0 / self.rate.fps as f64; 92 | 93 | let packets = self.input_context.packets().filter_map(|(s, packet)| { 94 | if s.index() != stream_index { 95 | // ignore irrelevant streams 96 | None 97 | } else { 98 | pts_last_packet = packet.pts()? + packet.duration(); 99 | Some(packet) 100 | } 101 | }) 102 | // extra packet to flush remaining frames 103 | .chain(std::iter::once(ffmpeg::Packet::empty())); 104 | 105 | for packet in packets { 106 | decoder.send_packet(&packet)?; 107 | loop { 108 | match decoder.receive_frame(&mut vid_frame) { 109 | Ok(()) => (), 110 | Err(ffmpeg::Error::Other { errno: ffmpeg::error::EAGAIN }) | Err(ffmpeg::Error::Eof) => break, 111 | Err(e) => return Err(Box::new(e)), 112 | } 113 | filter.get("in").ok_or("ffmpeg format error")?.source().add(&vid_frame)?; 114 | let mut out = filter.get("out").ok_or("ffmpeg format error")?; 115 | let mut out = out.sink(); 116 | while let Ok(..) = out.frame(&mut filt_frame) { 117 | add_frame(&filt_frame, pts_frame_step * i as f64, i)?; 118 | i += 1; 119 | } 120 | } 121 | } 122 | 123 | // now flush filter's buffer 124 | filter.get("in").ok_or("ffmpeg format error")?.source().close(pts_last_packet)?; 125 | let mut out = filter.get("out").ok_or("ffmpeg format error")?; 126 | let mut out = out.sink(); 127 | while let Ok(..) = out.frame(&mut filt_frame) { 128 | add_frame(&filt_frame, pts_frame_step * i as f64, i)?; 129 | i += 1; 130 | } 131 | Ok(()) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/bin/gif_source.rs: -------------------------------------------------------------------------------- 1 | //! This is for reading GIFs as an input for re-encoding as another GIF 2 | 3 | use crate::source::{Fps, Source}; 4 | use crate::{BinResult, SrcPath}; 5 | use gif::Decoder; 6 | use gifski::Collector; 7 | use std::io::Read; 8 | 9 | pub struct GifDecoder { 10 | speed: f32, 11 | decoder: Decoder>, 12 | screen: gif_dispose::Screen, 13 | } 14 | 15 | impl GifDecoder { 16 | pub fn new(src: SrcPath, fps: Fps) -> BinResult { 17 | let input = match src { 18 | SrcPath::Path(path) => Box::new(std::fs::File::open(path)?) as Box, 19 | SrcPath::Stdin(buf) => Box::new(buf), 20 | }; 21 | 22 | let mut gif_opts = gif::DecodeOptions::new(); 23 | // Important: 24 | gif_opts.set_color_output(gif::ColorOutput::Indexed); 25 | 26 | let decoder = gif_opts.read_info(input)?; 27 | let screen = gif_dispose::Screen::new_decoder(&decoder); 28 | 29 | Ok(Self { 30 | speed: fps.speed, 31 | decoder, 32 | screen, 33 | }) 34 | } 35 | } 36 | 37 | impl Source for GifDecoder { 38 | fn total_frames(&self) -> Option { None } 39 | fn collect(&mut self, c: &mut Collector) -> BinResult<()> { 40 | let mut idx = 0; 41 | let mut delay_ts = 0; 42 | while let Some(frame) = self.decoder.read_next_frame()? { 43 | self.screen.blit_frame(frame)?; 44 | let pixels = self.screen.pixels_rgba().map_buf(|b| b.to_owned()); 45 | let presentation_timestamp = f64::from(delay_ts) * (1. / (100. * f64::from(self.speed))); 46 | c.add_frame_rgba(idx, pixels, presentation_timestamp)?; 47 | idx += 1; 48 | delay_ts += u32::from(frame.delay); 49 | } 50 | Ok(()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/bin/gifski.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::bool_to_int_with_if)] 2 | #![allow(clippy::cast_possible_truncation)] 3 | #![allow(clippy::enum_glob_use)] 4 | #![allow(clippy::match_same_arms)] 5 | #![allow(clippy::missing_errors_doc)] 6 | #![allow(clippy::module_name_repetitions)] 7 | #![allow(clippy::needless_pass_by_value)] 8 | #![allow(clippy::redundant_closure_for_method_calls)] 9 | #![allow(clippy::wildcard_imports)] 10 | 11 | use clap::builder::NonEmptyStringValueParser; 12 | use clap::error::ErrorKind::MissingRequiredArgument; 13 | use clap::value_parser; 14 | use yuv::color::MatrixCoefficients; 15 | use gifski::{Repeat, Settings}; 16 | use std::io::stdin; 17 | use std::io::BufRead; 18 | use std::io::BufReader; 19 | use std::io::IsTerminal; 20 | use std::io::Read; 21 | use std::io::StdinLock; 22 | use std::io::Stdout; 23 | 24 | #[cfg(feature = "video")] 25 | mod ffmpeg_source; 26 | mod gif_source; 27 | mod png; 28 | mod source; 29 | mod y4m_source; 30 | use crate::source::Source; 31 | 32 | use gifski::progress::{NoProgress, ProgressReporter}; 33 | 34 | pub type BinResult> = Result; 35 | 36 | use clap::{Arg, ArgAction, Command}; 37 | 38 | use std::env; 39 | use std::fmt; 40 | use std::fs::File; 41 | use std::io; 42 | use std::path::{Path, PathBuf}; 43 | use std::thread; 44 | use std::time::Duration; 45 | 46 | #[cfg(feature = "video")] 47 | const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg, or multiple PNG image files"; 48 | #[cfg(not(feature = "video"))] 49 | const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation frames, or a .y4m file"; 50 | 51 | fn main() { 52 | if let Err(e) = bin_main() { 53 | eprintln!("error: {e}"); 54 | if let Some(e) = e.source() { 55 | eprintln!("error: {e}"); 56 | } 57 | std::process::exit(1); 58 | } 59 | } 60 | 61 | #[allow(clippy::float_cmp)] 62 | fn bin_main() -> BinResult<()> { 63 | let matches = Command::new(clap::crate_name!()) 64 | .version(clap::crate_version!()) 65 | .about("https://gif.ski by Kornel Lesiński") 66 | .arg_required_else_help(true) 67 | .allow_negative_numbers(true) 68 | .arg(Arg::new("output") 69 | .long("output") 70 | .short('o') 71 | .help("Destination file to write to; \"-\" means stdout") 72 | .num_args(1) 73 | .value_name("a.gif") 74 | .value_parser(value_parser!(PathBuf)) 75 | .required(true)) 76 | .arg(Arg::new("fps") 77 | .long("fps") 78 | .short('r') 79 | .help("Frame rate of animation. If using PNG files as \ 80 | input, this means the speed, as all frames are \ 81 | kept.\nIf video is used, it will be resampled to \ 82 | this constant rate by dropping and/or duplicating \ 83 | frames") 84 | .value_parser(value_parser!(f32)) 85 | .value_name("num") 86 | .default_value("20")) 87 | .arg(Arg::new("fast-forward") 88 | .long("fast-forward") 89 | .help("Multiply speed of video by a factor") 90 | .value_parser(value_parser!(f32)) 91 | .value_name("x") 92 | .default_value("1")) 93 | .arg(Arg::new("fast") 94 | .num_args(0) 95 | .action(ArgAction::SetTrue) 96 | .long("fast") 97 | .help("50% faster encoding, but 10% worse quality and larger file size")) 98 | .arg(Arg::new("extra") 99 | .long("extra") 100 | .conflicts_with("fast") 101 | .num_args(0) 102 | .action(ArgAction::SetTrue) 103 | .help("50% slower encoding, but 1% better quality and usually larger file size")) 104 | .arg(Arg::new("quality") 105 | .long("quality") 106 | .short('Q') 107 | .value_name("1-100") 108 | .value_parser(value_parser!(u8).range(1..=100)) 109 | .num_args(1) 110 | .default_value("90") 111 | .help("Lower quality may give smaller file")) 112 | .arg(Arg::new("motion-quality") 113 | .long("motion-quality") 114 | .value_name("1-100") 115 | .value_parser(value_parser!(u8).range(1..=100)) 116 | .num_args(1) 117 | .help("Lower values reduce motion")) 118 | .arg(Arg::new("lossy-quality") 119 | .long("lossy-quality") 120 | .value_name("1-100") 121 | .value_parser(value_parser!(u8).range(1..=100)) 122 | .num_args(1) 123 | .help("Lower values introduce noise and streaks")) 124 | .arg(Arg::new("width") 125 | .long("width") 126 | .short('W') 127 | .num_args(1) 128 | .value_parser(value_parser!(u32)) 129 | .value_name("px") 130 | .help("Maximum width.\nBy default anims are limited to about 800x600")) 131 | .arg(Arg::new("height") 132 | .long("height") 133 | .short('H') 134 | .num_args(1) 135 | .value_parser(value_parser!(u32)) 136 | .value_name("px") 137 | .help("Maximum height (stretches if the width is also set)")) 138 | .arg(Arg::new("nosort") 139 | .alias("nosort") 140 | .long("no-sort") 141 | .num_args(0) 142 | .action(ArgAction::SetTrue) 143 | .hide_short_help(true) 144 | .help("Use files exactly in the order given, rather than sorted")) 145 | .arg(Arg::new("quiet") 146 | .long("quiet") 147 | .short('q') 148 | .num_args(0) 149 | .action(ArgAction::SetTrue) 150 | .help("Do not display anything on standard output/console")) 151 | .arg(Arg::new("FILES") 152 | .help(VIDEO_FRAMES_ARG_HELP) 153 | .num_args(1..) 154 | .value_parser(NonEmptyStringValueParser::new()) 155 | .use_value_delimiter(false) 156 | .required(true)) 157 | .arg(Arg::new("repeat") 158 | .long("repeat") 159 | .help("Number of times the animation is repeated (-1 none, 0 forever or repetitions") 160 | .num_args(1) 161 | .value_parser(value_parser!(i16)) 162 | .value_name("num")) 163 | .arg(Arg::new("bounce") 164 | .long("bounce") 165 | .num_args(0) 166 | .action(ArgAction::SetTrue) 167 | .hide_short_help(true) 168 | .help("Make animation play forwards then backwards")) 169 | .arg(Arg::new("fixed-color") 170 | .long("fixed-color") 171 | .help("Always include this color in the palette") 172 | .hide_short_help(true) 173 | .num_args(1) 174 | .action(ArgAction::Append) 175 | .value_parser(parse_colors) 176 | .value_name("RGBHEX")) 177 | .arg(Arg::new("matte") 178 | .long("matte") 179 | .help("Background color for semitransparent pixels") 180 | .num_args(1) 181 | .value_parser(parse_color) 182 | .value_name("RGBHEX")) 183 | .arg(Arg::new("y4m-color-override") 184 | .long("y4m-color-override") 185 | .help("The color space of the input YUV4MPEG2 video\n\ 186 | Possible values: bt709 fcc bt470bg bt601 smpte240 ycgco\n\ 187 | Defaults to bt709 for HD and bt601 for SD resolutions") 188 | .num_args(1) 189 | .hide_short_help(true) 190 | .value_parser(parse_color_space) 191 | .value_name("bt709")) 192 | .try_get_matches_from(wild::args_os()) 193 | .unwrap_or_else(|e| { 194 | if e.kind() == MissingRequiredArgument && !stdin().is_terminal() { 195 | eprintln!("If you're trying to pipe a file, use \"-\" as the input file name"); 196 | } 197 | e.exit() 198 | }); 199 | 200 | let mut frames: Vec<&str> = matches.get_many::("FILES").ok_or("?")?.map(|s| s.as_str()).collect(); 201 | let bounce = matches.get_flag("bounce"); 202 | if !matches.get_flag("nosort") && frames.len() > 1 { 203 | frames.sort_by(|a, b| natord::compare(a, b)); 204 | } 205 | let mut frames: Vec<_> = frames.into_iter().map(PathBuf::from).collect(); 206 | 207 | let output_path = DestPath::new(matches.get_one::("output").ok_or("?")?); 208 | let width = matches.get_one::("width").copied(); 209 | let height = matches.get_one::("height").copied(); 210 | let repeat_int = matches.get_one::("repeat").copied().unwrap_or(0); 211 | let repeat = match repeat_int { 212 | -1 => Repeat::Finite(0), 213 | 0 => Repeat::Infinite, 214 | _ => Repeat::Finite(repeat_int as u16), 215 | }; 216 | 217 | let extra = matches.get_flag("extra"); 218 | let motion_quality = matches.get_one::("motion-quality").copied(); 219 | let lossy_quality = matches.get_one::("lossy-quality").copied(); 220 | let fast = matches.get_flag("fast"); 221 | let settings = Settings { 222 | width, 223 | height, 224 | quality: matches.get_one::("quality").copied().unwrap_or(100), 225 | fast, 226 | repeat, 227 | }; 228 | let quiet = matches.get_flag("quiet") || output_path == DestPath::Stdout; 229 | let fps: f32 = matches.get_one::("fps").copied().ok_or("?")?; 230 | let speed: f32 = matches.get_one::("fast-forward").copied().ok_or("?")?; 231 | let fixed_colors = matches.get_many::>("fixed-color"); 232 | let matte = matches.get_one::("matte"); 233 | let in_color_space = matches.get_one::("y4m-color-override").copied(); 234 | 235 | let rate = source::Fps { fps, speed }; 236 | 237 | if settings.quality < 20 { 238 | if settings.quality < 1 { 239 | return Err("Quality too low".into()); 240 | } else if !quiet { 241 | eprintln!("warning: quality {} will give really bad results", settings.quality); 242 | } 243 | } else if settings.quality > 100 { 244 | return Err("Quality 100 is maximum".into()); 245 | } 246 | 247 | if speed > 1000.0 || speed <= 0.0 { 248 | return Err("Fast-forward must be 0..1000".into()); 249 | } 250 | 251 | if fps > 100.0 || fps <= 0.0 { 252 | return Err("100 fps is maximum".into()); 253 | } else if !quiet && fps > 50.0 { 254 | eprintln!("warning: web browsers support max 50 fps"); 255 | } 256 | 257 | check_if_paths_exist(&frames)?; 258 | 259 | std::thread::scope(move |scope| { 260 | 261 | let (mut collector, mut writer) = gifski::new(settings)?; 262 | if let Some(fixed_colors) = fixed_colors { 263 | for f in fixed_colors.flatten() { 264 | writer.add_fixed_color(*f); 265 | } 266 | } 267 | if let Some(matte) = matte { 268 | #[allow(deprecated)] 269 | writer.set_matte_color(*matte); 270 | } 271 | if extra { 272 | #[allow(deprecated)] 273 | writer.set_extra_effort(true); 274 | } 275 | if let Some(motion_quality) = motion_quality { 276 | #[allow(deprecated)] 277 | writer.set_motion_quality(motion_quality); 278 | } 279 | if let Some(lossy_quality) = lossy_quality { 280 | #[allow(deprecated)] 281 | writer.set_lossy_quality(lossy_quality); 282 | } 283 | 284 | let (decoder_ready_send, decoder_ready_recv) = crossbeam_channel::bounded(1); 285 | 286 | let decode_thread = thread::Builder::new().name("decode".into()).spawn_scoped(scope, move || { 287 | let mut decoder = if let [path] = &frames[..] { 288 | if bounce { 289 | eprintln!("warning: the bounce flag is supported only for individual files, not pipe or video"); 290 | } 291 | let mut src = if path.as_os_str() == "-" { 292 | let fd = stdin().lock(); 293 | if fd.is_terminal() { 294 | eprintln!("warning: used '-' as the input path, but the stdin is a terminal, not a file."); 295 | } 296 | SrcPath::Stdin(BufReader::new(fd)) 297 | } else { 298 | SrcPath::Path(path.clone()) 299 | }; 300 | match file_type(&mut src).unwrap_or(FileType::Other) { 301 | FileType::PNG | FileType::JPEG => return Err("Only a single image file was given as an input. This is not enough to make an animation.".into()), 302 | FileType::GIF => { 303 | if !quiet && (width.is_none() && settings.quality > 50) { 304 | eprintln!("warning: reading an existing GIF as an input. This can only worsen the quality. Use PNG frames instead."); 305 | } 306 | Box::new(gif_source::GifDecoder::new(src, rate)?) 307 | }, 308 | _ if path.is_dir() => { 309 | return Err(format!("{} is a directory, not a PNG file", path.display()).into()); 310 | }, 311 | other_type => get_video_decoder(other_type, src, rate, in_color_space, settings)?, 312 | } 313 | } else { 314 | if bounce { 315 | let mut extra: Vec<_> = frames.iter().skip(1).rev().cloned().collect(); 316 | frames.append(&mut extra); 317 | } 318 | if speed != 1.0 { 319 | eprintln!("warning: --fast-forward option is for videos. It doesn't make sense for images. Use --fps only."); 320 | } 321 | let file_type = file_type(&mut SrcPath::Path(frames[0].clone())).unwrap_or(FileType::Other); 322 | match file_type { 323 | FileType::JPEG => { 324 | return Err("JPEG format is unsuitable for conversion to GIF.\n\n\ 325 | JPEG's compression artifacts and color space are very problematic for palette-based\n\ 326 | compression. Please don't use JPEG for making GIF animations. Please re-export\n\ 327 | your animation using the PNG format.".into()) 328 | }, 329 | FileType::GIF => return unexpected("GIF"), 330 | FileType::Y4M => return unexpected("Y4M"), 331 | _ => Box::new(png::Lodecoder::new(frames, rate)), 332 | } 333 | }; 334 | 335 | decoder_ready_send.send(decoder.total_frames())?; 336 | 337 | decoder.collect(&mut collector) 338 | })?; 339 | 340 | let mut file_tmp; 341 | let mut stdio_tmp; 342 | let mut print_terminal_err = false; 343 | let out: &mut dyn io::Write = match output_path { 344 | DestPath::Path(path) => { 345 | file_tmp = File::create(path) 346 | .map_err(|err| { 347 | let mut msg = format!("Can't write to \"{}\": {err}", path.display()); 348 | let canon = path.canonicalize(); 349 | if let Some(parent) = canon.as_deref().unwrap_or(path).parent() { 350 | if parent.as_os_str() != "" { 351 | use std::fmt::Write; 352 | match parent.try_exists() { 353 | Ok(true) => {}, 354 | Ok(false) => { 355 | let _ = write!(&mut msg, " (directory \"{}\" doesn't exist)", parent.display()); 356 | }, 357 | Err(err) => { 358 | let _ = write!(&mut msg, " (directory \"{}\" is not accessible: {err})", parent.display()); 359 | }, 360 | } 361 | } 362 | } 363 | msg 364 | })?; 365 | &mut file_tmp 366 | }, 367 | DestPath::Stdout => { 368 | stdio_tmp = io::stdout().lock(); 369 | print_terminal_err = stdio_tmp.is_terminal(); 370 | &mut stdio_tmp 371 | }, 372 | }; 373 | 374 | let total_frames = match decoder_ready_recv.recv() { 375 | Ok(t) => t, 376 | Err(_) => { 377 | // if the decoder failed to start, 378 | // writer won't have any interesting error to report 379 | return decode_thread.join().map_err(panic_err)?; 380 | } 381 | }; 382 | 383 | let mut pb; 384 | let mut nopb = NoProgress {}; 385 | let progress: &mut dyn ProgressReporter = if quiet { 386 | &mut nopb 387 | } else { 388 | pb = ProgressBar::new(total_frames); 389 | &mut pb 390 | }; 391 | 392 | if print_terminal_err { 393 | eprintln!("warning: used '-' as the output path, but the stdout is a terminal, not a file"); 394 | std::thread::sleep(Duration::from_secs(3)); 395 | } 396 | let write_result = writer.write(io::BufWriter::new(out), progress); 397 | let thread_result = decode_thread.join().map_err(panic_err)?; 398 | check_errors(write_result, thread_result)?; 399 | progress.done(&format!("gifski created {output_path}")); 400 | 401 | Ok(()) 402 | }) 403 | } 404 | 405 | fn check_errors(err1: Result<(), gifski::Error>, err2: BinResult<()>) -> BinResult<()> { 406 | use gifski::Error::*; 407 | match err1 { 408 | Ok(()) => err2, 409 | Err(ThreadSend | Aborted | NoFrames) if err2.is_err() => err2, 410 | Err(err1) => Err(err1.into()), 411 | } 412 | } 413 | 414 | #[cold] 415 | fn unexpected(ftype: &'static str) -> BinResult<()> { 416 | Err(format!("Too many arguments. Unexpectedly got a {ftype} as an input frame. Only PNG format is supported for individual frames.").into()) 417 | } 418 | 419 | #[cold] 420 | fn panic_err(err: Box) -> String { 421 | err.downcast::().map(|s| *s) 422 | .unwrap_or_else(|e| e.downcast_ref::<&str>().copied().unwrap_or("panic").to_owned()) 423 | } 424 | 425 | fn parse_color(c: &str) -> Result { 426 | let c = c.trim_matches(|c: char| c.is_ascii_whitespace()); 427 | let c = c.strip_prefix('#').unwrap_or(c); 428 | 429 | if c.len() != 6 { 430 | return Err(format!("color must be 6-char hex format, not '{c}'")); 431 | } 432 | let mut c = c.as_bytes().chunks_exact(2) 433 | .map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap_or_default(), 16).map_err(|e| e.to_string())); 434 | Ok(rgb::RGB8::new( 435 | c.next().ok_or_else(String::new)??, 436 | c.next().ok_or_else(String::new)??, 437 | c.next().ok_or_else(String::new)??, 438 | )) 439 | } 440 | 441 | fn parse_colors(colors: &str) -> Result, String> { 442 | colors.split([' ', ',']) 443 | .filter(|c| !c.is_empty()) 444 | .map(parse_color) 445 | .collect() 446 | } 447 | 448 | #[test] 449 | fn color_parser() { 450 | assert_eq!(parse_colors("#123456 78abCD,, ,").unwrap(), vec![rgb::RGB8::new(0x12, 0x34, 0x56), rgb::RGB8::new(0x78, 0xab, 0xcd)]); 451 | assert!(parse_colors("#12345").is_err()); 452 | } 453 | 454 | fn parse_color_space(value: &str) -> Result { 455 | let value = value.to_lowercase(); 456 | let value = value.trim(); 457 | let matrix = match value { 458 | "bt709" => MatrixCoefficients::BT709, 459 | "fcc" => MatrixCoefficients::FCC, 460 | "bt470bg" => MatrixCoefficients::BT470BG, 461 | "bt601" => MatrixCoefficients::BT601, 462 | "smpte240" => MatrixCoefficients::SMPTE240, 463 | "ycgco" => MatrixCoefficients::YCgCo, 464 | _ => return Err("unsupported color space".into()), 465 | }; 466 | Ok(matrix) 467 | } 468 | 469 | #[allow(clippy::upper_case_acronyms)] 470 | #[derive(PartialEq)] 471 | enum FileType { 472 | PNG, GIF, JPEG, Y4M, Other, 473 | } 474 | 475 | fn file_type(src: &mut SrcPath) -> BinResult { 476 | let mut buf = [0; 4]; 477 | match src { 478 | SrcPath::Path(path) => match path.extension() { 479 | Some(e) if e.eq_ignore_ascii_case("y4m") => return Ok(FileType::Y4M), 480 | Some(e) if e.eq_ignore_ascii_case("png") => return Ok(FileType::PNG), 481 | _ => { 482 | let mut file = std::fs::File::open(path)?; 483 | file.read_exact(&mut buf)?; 484 | }, 485 | }, 486 | SrcPath::Stdin(stdin) => { 487 | let buf_in = stdin.fill_buf()?; 488 | let max_len = buf_in.len().min(4); 489 | buf[..max_len].copy_from_slice(&buf_in[..max_len]); 490 | // don't consume 491 | }, 492 | } 493 | 494 | if &buf == b"\x89PNG" { 495 | return Ok(FileType::PNG); 496 | } 497 | if &buf == b"GIF8" { 498 | return Ok(FileType::GIF); 499 | } 500 | if &buf == b"YUV4" { 501 | return Ok(FileType::Y4M); 502 | } 503 | if buf[..2] == [0xFF, 0xD8] { 504 | return Ok(FileType::JPEG); 505 | } 506 | Ok(FileType::Other) 507 | } 508 | 509 | fn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> { 510 | for path in paths { 511 | // stdin is ok 512 | if path.as_os_str() == "-" && paths.len() == 1 { 513 | break; 514 | } 515 | let mut msg = match path.try_exists() { 516 | Ok(true) => continue, 517 | Ok(false) => format!("Unable to find the input file: \"{}\"", path.display()), 518 | Err(err) => format!("Unable to access the input file \"{}\": {err}", path.display()), 519 | }; 520 | let canon = path.canonicalize(); 521 | if let Some(parent) = canon.as_deref().unwrap_or(path).parent() { 522 | if parent.as_os_str() != "" && matches!(path.try_exists(), Ok(false)) { 523 | use std::fmt::Write; 524 | if msg.len() > 80 { 525 | msg.push('\n'); 526 | } 527 | write!(&mut msg, " (directory \"{}\" doesn't exist either)", parent.display())?; 528 | } 529 | } 530 | if path.to_str().is_some_and(|p| p.contains(['*', '?', '['])) { 531 | msg += "\nThe wildcard pattern did not match any files."; 532 | } else if path.is_relative() { 533 | use std::fmt::Write; 534 | write!(&mut msg, " (searched in \"{}\")", env::current_dir()?.display())?; 535 | } 536 | if path.extension() == Some("gif".as_ref()) { 537 | msg = format!("\nDid you mean to use -o \"{}\" to specify it as the output file instead?", path.display()); 538 | } 539 | return Err(msg.into()); 540 | } 541 | Ok(()) 542 | } 543 | 544 | #[derive(PartialEq)] 545 | enum DestPath<'a> { 546 | Path(&'a Path), 547 | Stdout, 548 | } 549 | 550 | enum SrcPath { 551 | Path(PathBuf), 552 | Stdin(BufReader>), 553 | } 554 | 555 | impl<'a> DestPath<'a> { 556 | pub fn new(path: &'a Path) -> Self { 557 | if path.as_os_str() == "-" { 558 | Self::Stdout 559 | } else { 560 | Self::Path(Path::new(path)) 561 | } 562 | } 563 | } 564 | 565 | impl fmt::Display for DestPath<'_> { 566 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 567 | match self { 568 | Self::Path(orig_path) => { 569 | let abs_path = dunce::canonicalize(orig_path); 570 | abs_path.as_ref().map(|p| p.as_path()).unwrap_or(orig_path).display().fmt(f) 571 | }, 572 | Self::Stdout => f.write_str("stdout"), 573 | } 574 | } 575 | } 576 | 577 | #[cfg(feature = "video")] 578 | fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, settings: Settings) -> BinResult> { 579 | Ok(if ftype == FileType::Y4M { 580 | Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?) 581 | } else { 582 | Box::new(ffmpeg_source::FfmpegDecoder::new(src, fps, settings)?) 583 | }) 584 | } 585 | 586 | #[cfg(not(feature = "video"))] 587 | #[cold] 588 | fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, _: Settings) -> BinResult> { 589 | if ftype == FileType::Y4M { 590 | Ok(Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?)) 591 | } else { 592 | let path = match &src { 593 | SrcPath::Path(path) => path, 594 | SrcPath::Stdin(_) => Path::new("video.mp4"), 595 | }; 596 | let rel_path = path.file_name().map_or(path, Path::new); 597 | Err(format!(r#"Video support is permanently disabled in this distribution of gifski. 598 | 599 | The only 'video' format supported at this time is YUV4MPEG2, which can be piped from ffmpeg: 600 | 601 | ffmpeg -i "{src}" -f yuv4mpegpipe - | gifski -o "{gif}" - 602 | 603 | To enable full video decoding you need to recompile gifski from source. 604 | https://github.com/imageoptim/gifski 605 | 606 | Alternatively, use ffmpeg or other tool to export PNG frames, and then specify 607 | the PNG files as input for this executable. Instructions on https://gif.ski 608 | "#, 609 | src = path.display(), 610 | gif = rel_path.with_extension("gif").display() 611 | ).into()) 612 | } 613 | } 614 | 615 | struct ProgressBar { 616 | pb: pbr::ProgressBar, 617 | frames: u64, 618 | total: Option, 619 | previous_estimate: u64, 620 | displayed_estimate: u64, 621 | } 622 | impl ProgressBar { 623 | fn new(total: Option) -> Self { 624 | let mut pb = pbr::ProgressBar::new(total.unwrap_or(100)); 625 | pb.show_speed = false; 626 | pb.show_percent = false; 627 | pb.format(" #_. "); 628 | pb.message("Frame "); 629 | pb.set_max_refresh_rate(Some(Duration::from_millis(250))); 630 | Self { 631 | pb, frames: 0, total, previous_estimate: 0, displayed_estimate: 0, 632 | } 633 | } 634 | } 635 | 636 | impl ProgressReporter for ProgressBar { 637 | fn increase(&mut self) -> bool { 638 | self.frames += 1; 639 | if self.total.is_none() { 640 | self.pb.total = (self.frames + 50).max(100); 641 | } 642 | self.pb.inc(); 643 | true 644 | } 645 | 646 | fn written_bytes(&mut self, bytes: u64) { 647 | let min_frames = self.total.map_or(10, |t| (t / 16).clamp(5, 50)); 648 | if self.frames > min_frames { 649 | let total_size = bytes * self.pb.total / self.frames; 650 | let new_estimate = if total_size >= self.previous_estimate { total_size } else { (self.previous_estimate + total_size) / 2 }; 651 | self.previous_estimate = new_estimate; 652 | if self.displayed_estimate.abs_diff(new_estimate) > new_estimate / 10 { 653 | self.displayed_estimate = new_estimate; 654 | let (num, unit, x) = if new_estimate > 1_000_000 { 655 | (new_estimate as f64 / 1_000_000., "MB", if new_estimate > 10_000_000 { 0 } else { 1 }) 656 | } else { 657 | (new_estimate as f64 / 1_000., "KB", 0) 658 | }; 659 | self.pb.message(&format!("{num:.x$}{unit} GIF; Frame ")); 660 | } 661 | } 662 | } 663 | 664 | fn done(&mut self, msg: &str) { 665 | self.pb.finish_print(msg); 666 | } 667 | } 668 | -------------------------------------------------------------------------------- /src/bin/png.rs: -------------------------------------------------------------------------------- 1 | use crate::source::{Fps, Source}; 2 | use crate::BinResult; 3 | use gifski::Collector; 4 | use std::path::PathBuf; 5 | 6 | pub struct Lodecoder { 7 | frames: Vec, 8 | fps: f64, 9 | } 10 | 11 | impl Lodecoder { 12 | pub fn new(frames: Vec, params: Fps) -> Self { 13 | Self { 14 | frames, 15 | fps: f64::from(params.fps) * f64::from(params.speed), 16 | } 17 | } 18 | } 19 | 20 | impl Source for Lodecoder { 21 | fn total_frames(&self) -> Option { 22 | Some(self.frames.len() as u64) 23 | } 24 | 25 | #[inline(never)] 26 | fn collect(&mut self, dest: &mut Collector) -> BinResult<()> { 27 | let dest = &*dest; 28 | let f = std::mem::take(&mut self.frames); 29 | for (i, frame) in f.into_iter().enumerate() { 30 | dest.add_frame_png_file(i, frame, i as f64 / self.fps)?; 31 | } 32 | Ok(()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/bin/source.rs: -------------------------------------------------------------------------------- 1 | use crate::BinResult; 2 | use gifski::Collector; 3 | 4 | pub trait Source { 5 | fn total_frames(&self) -> Option; 6 | fn collect(&mut self, dest: &mut Collector) -> BinResult<()>; 7 | } 8 | 9 | #[derive(Debug, Copy, Clone)] 10 | pub struct Fps { 11 | /// output rate 12 | pub fps: f32, 13 | /// skip frames 14 | pub speed: f32, 15 | } 16 | -------------------------------------------------------------------------------- /src/bin/y4m_source.rs: -------------------------------------------------------------------------------- 1 | use std::io::BufReader; 2 | use std::io::Read; 3 | use imgref::ImgVec; 4 | use gifski::Collector; 5 | use y4m::{Colorspace, Decoder, ParseError}; 6 | use yuv::color::{MatrixCoefficients, Range}; 7 | use yuv::convert::RGBConvert; 8 | use yuv::YUV; 9 | use crate::{SrcPath, BinResult}; 10 | use crate::source::{Fps, Source}; 11 | 12 | pub struct Y4MDecoder { 13 | fps: Fps, 14 | in_color_space: Option, 15 | decoder: Decoder>>, 16 | file_size: Option, 17 | } 18 | 19 | impl Y4MDecoder { 20 | pub fn new(src: SrcPath, fps: Fps, in_color_space: Option) -> BinResult { 21 | let mut file_size = None; 22 | let reader = match src { 23 | SrcPath::Path(path) => { 24 | let f = std::fs::File::open(path)?; 25 | let m = f.metadata()?; 26 | #[cfg(unix)] { 27 | use std::os::unix::fs::MetadataExt; 28 | file_size = Some(m.size()); 29 | } 30 | #[cfg(windows)] { 31 | use std::os::windows::fs::MetadataExt; 32 | file_size = Some(m.file_size()); 33 | } 34 | Box::new(BufReader::new(f)) as Box> 35 | }, 36 | SrcPath::Stdin(buf) => Box::new(buf) as Box>, 37 | }; 38 | 39 | Ok(Self { 40 | file_size, 41 | fps, 42 | in_color_space, 43 | decoder: Decoder::new(reader).map_err(|e| match e { 44 | y4m::Error::EOF => "The y4m file is truncated or invalid", 45 | y4m::Error::BadInput => "The y4m file contains invalid metadata", 46 | y4m::Error::UnknownColorspace => "y4m uses an unusual color format that is not supported", 47 | y4m::Error::OutOfMemory => "Out of memory, or the y4m file has bogus dimensions", 48 | y4m::Error::ParseError(ParseError::InvalidY4M) => "The input is not a y4m file", 49 | y4m::Error::ParseError(error) => return format!("y4m contains invalid data: {error}"), 50 | y4m::Error::IoError(error) => return format!("I/O error when reading a y4m file: {error}"), 51 | }.to_string())?, 52 | }) 53 | } 54 | } 55 | 56 | enum Samp { 57 | Mono, 58 | S1x1, 59 | S2x1, 60 | S2x2, 61 | } 62 | 63 | impl Source for Y4MDecoder { 64 | fn total_frames(&self) -> Option { 65 | self.file_size.map(|file_size| { 66 | let w = self.decoder.get_width(); 67 | let h = self.decoder.get_height(); 68 | let d = self.decoder.get_bytes_per_sample(); 69 | let s = match self.decoder.get_colorspace() { 70 | Colorspace::Cmono => 4, 71 | Colorspace::Cmono12 => 4, 72 | Colorspace::C420 => 6, 73 | Colorspace::C420p10 => 6, 74 | Colorspace::C420p12 => 6, 75 | Colorspace::C420jpeg => 6, 76 | Colorspace::C420paldv => 6, 77 | Colorspace::C420mpeg2 => 6, 78 | Colorspace::C422 => 8, 79 | Colorspace::C422p10 => 8, 80 | Colorspace::C422p12 => 8, 81 | Colorspace::C444 => 12, 82 | Colorspace::C444p10 => 12, 83 | Colorspace::C444p12 => 12, 84 | _ => 12, 85 | }; 86 | file_size.saturating_sub(self.decoder.get_raw_params().len() as _) / (w * h * d * s / 4 + 6) as u64 87 | }) 88 | } 89 | 90 | fn collect(&mut self, c: &mut Collector) -> BinResult<()> { 91 | let fps = self.decoder.get_framerate(); 92 | let frame_time = 1. / (fps.num as f64 / fps.den as f64); 93 | let wanted_frame_time = 1. / f64::from(self.fps.fps); 94 | let width = self.decoder.get_width(); 95 | let height = self.decoder.get_height(); 96 | let raw_params_str = &*String::from_utf8_lossy(self.decoder.get_raw_params()).into_owned(); 97 | let range = raw_params_str.split_once("COLORRANGE=").map(|(_, r)| { 98 | if r.starts_with("FULL") { Range::Full } else { Range::Limited } 99 | }); 100 | 101 | let matrix = self.in_color_space.unwrap_or({ 102 | if height <= 480 && width <= 720 { MatrixCoefficients::BT601 } else { MatrixCoefficients::BT709 } 103 | }); 104 | 105 | let (samp, conv) = match self.decoder.get_colorspace() { 106 | Colorspace::Cmono => (Samp::Mono, RGBConvert::::new(range.unwrap_or(Range::Limited), MatrixCoefficients::Identity)), 107 | Colorspace::Cmono12 => return Err("Y4M with Cmono12 is not supported yet".into()), 108 | Colorspace::C420 => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), 109 | Colorspace::C420p10 => return Err("Y4M with C420p10 is not supported yet".into()), 110 | Colorspace::C420p12 => return Err("Y4M with C420p12 is not supported yet".into()), 111 | Colorspace::C420jpeg => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), 112 | Colorspace::C420paldv => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), 113 | Colorspace::C420mpeg2 => (Samp::S2x2, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), 114 | Colorspace::C422 => (Samp::S2x1, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), 115 | Colorspace::C422p10 => return Err("Y4M with C422p10 is not supported yet".into()), 116 | Colorspace::C422p12 => return Err("Y4M with C422p12 is not supported yet".into()), 117 | Colorspace::C444 => (Samp::S1x1, RGBConvert::::new(range.unwrap_or(Range::Limited), matrix)), 118 | Colorspace::C444p10 => return Err("Y4M with C444p10 is not supported yet".into()), 119 | Colorspace::C444p12 => return Err("Y4M with C444p12 is not supported yet".into()), 120 | _ => return Err(format!("Y4M uses unsupported color mode {raw_params_str}").into()), 121 | }; 122 | let conv = conv?; 123 | if width == 0 || width > u16::MAX as _ || height == 0 || height > u16::MAX as _ { 124 | return Err("Video too large".into()); 125 | } 126 | 127 | #[cold] 128 | fn bad_frame(mode: &str) -> BinResult<()> { 129 | Err(format!("Bad Y4M frame (using {mode})").into()) 130 | } 131 | 132 | let mut idx = 0; 133 | let mut presentation_timestamp = 0.0; 134 | let mut wanted_pts = 0.0; 135 | loop { 136 | match self.decoder.read_frame() { 137 | Ok(frame) => { 138 | let this_frame_pts = presentation_timestamp / f64::from(self.fps.speed); 139 | presentation_timestamp += frame_time; 140 | if presentation_timestamp < wanted_pts { 141 | continue; // skip a frame 142 | } 143 | wanted_pts += wanted_frame_time; 144 | 145 | let y = frame.get_y_plane(); 146 | if y.is_empty() { 147 | return bad_frame(raw_params_str); 148 | } 149 | let u = frame.get_u_plane(); 150 | let v = frame.get_v_plane(); 151 | if v.len() != u.len() { 152 | return bad_frame(raw_params_str); 153 | } 154 | 155 | let mut out = Vec::new(); 156 | out.try_reserve(width * height)?; 157 | match samp { 158 | Samp::Mono => todo!(), 159 | Samp::S1x1 => { 160 | if v.len() != y.len() { 161 | return bad_frame(raw_params_str); 162 | } 163 | 164 | let y = y.chunks_exact(width); 165 | let u = u.chunks_exact(width); 166 | let v = v.chunks_exact(width); 167 | if y.len() != v.len() { 168 | return bad_frame(raw_params_str); 169 | } 170 | for (y, (u, v)) in y.zip(u.zip(v)) { 171 | out.extend( 172 | y.iter().copied().zip(u.iter().copied().zip(v.iter().copied())) 173 | .map(|(y, (u, v))| { 174 | conv.to_rgb(YUV {y, u, v}).with_alpha(255) 175 | })); 176 | } 177 | }, 178 | Samp::S2x1 => { 179 | let y = y.chunks_exact(width); 180 | let u = u.chunks_exact(width.div_ceil(2)); 181 | let v = v.chunks_exact(width.div_ceil(2)); 182 | if y.len() != v.len() { 183 | return bad_frame(raw_params_str); 184 | } 185 | for (y, (u, v)) in y.zip(u.zip(v)) { 186 | let u = u.iter().copied().flat_map(|x| [x, x]); 187 | let v = v.iter().copied().flat_map(|x| [x, x]); 188 | out.extend( 189 | y.iter().copied().zip(u.zip(v)) 190 | .map(|(y, (u, v))| { 191 | conv.to_rgb(YUV {y, u, v}).with_alpha(255) 192 | })); 193 | } 194 | }, 195 | Samp::S2x2 => { 196 | let y = y.chunks_exact(width); 197 | let u = u.chunks_exact(width.div_ceil(2)).flat_map(|r| [r, r]); 198 | let v = v.chunks_exact(width.div_ceil(2)).flat_map(|r| [r, r]); 199 | for (y, (u, v)) in y.zip(u.zip(v)) { 200 | let u = u.iter().copied().flat_map(|x| [x, x]); 201 | let v = v.iter().copied().flat_map(|x| [x, x]); 202 | out.extend( 203 | y.iter().copied().zip(u.zip(v)) 204 | .map(|(y, (u, v))| { 205 | conv.to_rgb(YUV {y, u, v}).with_alpha(255) 206 | })); 207 | } 208 | }, 209 | } 210 | if out.len() != width * height { 211 | return bad_frame(raw_params_str); 212 | } 213 | let pixels = ImgVec::new(out, width, height); 214 | 215 | c.add_frame_rgba(idx, pixels, this_frame_pts)?; 216 | idx += 1; 217 | }, 218 | Err(y4m::Error::EOF) => break, 219 | Err(e) => return Err(e.into()), 220 | } 221 | } 222 | Ok(()) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/c_api.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::missing_safety_doc)] 2 | //! How to use from C 3 | //! 4 | //! ```c 5 | //! gifski *g = gifski_new(&(GifskiSettings){ 6 | //! .quality = 90, 7 | //! }); 8 | //! gifski_set_file_output(g, "file.gif"); 9 | //! 10 | //! for(int i=0; i < frames; i++) { 11 | //! int res = gifski_add_frame_rgba(g, i, width, height, buffer, 5); 12 | //! if (res != GIFSKI_OK) break; 13 | //! } 14 | //! int res = gifski_finish(g); 15 | //! if (res != GIFSKI_OK) return; 16 | //! ``` 17 | //! 18 | //! It's safe and efficient to call `gifski_add_frame_*` in a loop as fast as you can get frames, 19 | //! because it blocks and waits until previous frames are written. 20 | //! 21 | //! 22 | //! To cancel processing, make progress callback return 0 and call `gifski_finish()`. The write callback 23 | //! may still be called between the cancellation and `gifski_finish()` returning. 24 | //! 25 | //! To build as a library: 26 | //! 27 | //! ```bash 28 | //! cargo build --release --lib 29 | //! ``` 30 | //! 31 | //! it will create `target/release/libgifski.a` (static library) 32 | //! and `target/release/libgifski.so`/`dylib` or `gifski.dll` (dynamic library) 33 | //! 34 | //! Static is recommended. 35 | //! 36 | //! To build for iOS: 37 | //! 38 | //! ```bash 39 | //! rustup target add aarch64-apple-ios 40 | //! cargo build --release --lib --target aarch64-apple-ios 41 | //! ``` 42 | //! 43 | //! it will build `target/aarch64-apple-ios/release/libgifski.a` (ignore the warning about cdylib). 44 | 45 | use crate::{Collector, NoProgress, ProgressCallback, ProgressReporter, Repeat, Settings, Writer}; 46 | use imgref::{Img, ImgVec}; 47 | use rgb::{RGB8, RGBA8}; 48 | use std::fs; 49 | use std::ffi::{CStr, CString}; 50 | use std::fs::File; 51 | use std::io; 52 | use std::io::Write; 53 | use std::mem; 54 | use std::os::raw::{c_char, c_int, c_void}; 55 | use std::path::{Path, PathBuf}; 56 | use std::ptr; 57 | use std::slice; 58 | use std::thread; 59 | use std::sync::{Arc, Mutex}; 60 | mod c_api_error; 61 | use self::c_api_error::GifskiError; 62 | use std::panic::catch_unwind; 63 | 64 | /// Settings for creating a new encoder instance. See `gifski_new` 65 | #[repr(C)] 66 | #[derive(Copy, Clone)] 67 | pub struct GifskiSettings { 68 | /// Resize to max this width if non-0. 69 | pub width: u32, 70 | /// Resize to max this height if width is non-0. Note that aspect ratio is not preserved. 71 | pub height: u32, 72 | /// 1-100, but useful range is 50-100. Recommended to set to 90. 73 | pub quality: u8, 74 | /// Lower quality, but faster encode. 75 | pub fast: bool, 76 | /// If negative, looping is disabled. The number of times the sequence is repeated. 0 to loop forever. 77 | pub repeat: i16, 78 | } 79 | 80 | #[repr(C)] 81 | #[derive(Copy, Clone)] 82 | pub struct ARGB8 { 83 | pub a: u8, 84 | pub r: u8, 85 | pub g: u8, 86 | pub b: u8, 87 | } 88 | 89 | /// Opaque handle used in methods. Note that the handle pointer is actually `Arc`, 90 | /// but `Arc::into_raw` is nice enough to point past the counter. 91 | #[repr(C)] 92 | pub struct GifskiHandle { 93 | _opaque: usize, 94 | } 95 | pub struct GifskiHandleInternal { 96 | writer: Mutex>, 97 | collector: Mutex>, 98 | progress: Mutex>, 99 | error_callback: Mutex>>, 100 | /// Bool set to true when the thread has been set up, 101 | /// prevents re-setting of the thread after `finish()` 102 | write_thread: Mutex<(bool, Option>)>, 103 | } 104 | 105 | /// Call to start the process 106 | /// 107 | /// See `gifski_add_frame_png_file` and `gifski_end_adding_frames` 108 | /// 109 | /// Returns a handle for the other functions, or `NULL` on error (if the settings are invalid). 110 | #[no_mangle] 111 | pub unsafe extern "C" fn gifski_new(settings: *const GifskiSettings) -> *const GifskiHandle { 112 | let Some(settings) = settings.as_ref() else { 113 | return ptr::null_mut(); 114 | }; 115 | let s = Settings { 116 | width: if settings.width > 0 { Some(settings.width) } else { None }, 117 | height: if settings.height > 0 { Some(settings.height) } else { None }, 118 | quality: settings.quality, 119 | fast: settings.fast, 120 | repeat: if settings.repeat == -1 { Repeat::Finite(0) } else if settings.repeat == 0 { Repeat::Infinite } else { Repeat::Finite(settings.repeat as u16) }, 121 | }; 122 | 123 | if let Ok((collector, writer)) = crate::new(s) { 124 | Arc::into_raw(Arc::new(GifskiHandleInternal { 125 | writer: Mutex::new(Some(writer)), 126 | write_thread: Mutex::new((false, None)), 127 | collector: Mutex::new(Some(collector)), 128 | progress: Mutex::new(None), 129 | error_callback: Mutex::new(None), 130 | })) 131 | .cast::() 132 | } else { 133 | ptr::null_mut() 134 | } 135 | } 136 | 137 | /// Quality 1-100 of temporal denoising. Lower values reduce motion. Defaults to `settings.quality`. 138 | /// 139 | /// Only valid immediately after calling `gifski_new`, before any frames are added. 140 | #[no_mangle] 141 | pub unsafe extern "C" fn gifski_set_motion_quality(handle: *mut GifskiHandle, quality: u8) -> GifskiError { 142 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 143 | 144 | if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { 145 | #[allow(deprecated)] 146 | w.set_motion_quality(quality); 147 | GifskiError::OK 148 | } else { 149 | GifskiError::INVALID_STATE 150 | } 151 | } 152 | 153 | /// Quality 1-100 of gifsicle compression. Lower values add noise. Defaults to `settings.quality`. 154 | /// 155 | /// Has no effect if the `gifsicle` feature hasn't been enabled. 156 | /// Only valid immediately after calling `gifski_new`, before any frames are added. 157 | #[no_mangle] 158 | pub unsafe extern "C" fn gifski_set_lossy_quality(handle: *mut GifskiHandle, quality: u8) -> GifskiError { 159 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 160 | 161 | if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { 162 | #[allow(deprecated)] 163 | w.set_lossy_quality(quality); 164 | GifskiError::OK 165 | } else { 166 | GifskiError::INVALID_STATE 167 | } 168 | } 169 | 170 | /// If `true`, encoding will be significantly slower, but may look a bit better. 171 | /// 172 | /// Only valid immediately after calling `gifski_new`, before any frames are added. 173 | #[no_mangle] 174 | pub unsafe extern "C" fn gifski_set_extra_effort(handle: *mut GifskiHandle, extra: bool) -> GifskiError { 175 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 176 | 177 | if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { 178 | #[allow(deprecated)] 179 | w.set_extra_effort(extra); 180 | GifskiError::OK 181 | } else { 182 | GifskiError::INVALID_STATE 183 | } 184 | } 185 | 186 | /// Adds a fixed color that will be kept in the palette at all times. 187 | /// 188 | /// Only valid immediately after calling `gifski_new`, before any frames are added. 189 | #[no_mangle] 190 | pub unsafe extern "C" fn gifski_add_fixed_color(handle: *mut GifskiHandle, col_r: u8, col_g: u8, col_b: u8) -> GifskiError { 191 | let Some(g) = borrow(handle) else { 192 | return GifskiError::NULL_ARG; 193 | }; 194 | 195 | if let Ok(Some(w)) = g.writer.lock().as_deref_mut() { 196 | w.add_fixed_color(RGB8::new(col_r, col_g, col_b)); 197 | GifskiError::OK 198 | } else { 199 | GifskiError::INVALID_STATE 200 | } 201 | } 202 | 203 | /// Adds a frame to the animation. This function is asynchronous. 204 | /// 205 | /// File path must be valid UTF-8. 206 | /// 207 | /// `frame_number` orders frames (consecutive numbers starting from 0). 208 | /// You can add frames in any order, and they will be sorted by their `frame_number`. 209 | /// 210 | /// Presentation timestamp (PTS) is time in seconds, since start of the file, when this frame is to be displayed. 211 | /// For a 20fps video it could be `frame_number/20.0`. 212 | /// Frames with duplicate or out-of-order PTS will be skipped. 213 | /// 214 | /// The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. 215 | /// 216 | /// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. 217 | /// 218 | /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 219 | #[no_mangle] 220 | #[cfg(feature = "png")] 221 | pub unsafe extern "C" fn gifski_add_frame_png_file(handle: *const GifskiHandle, frame_number: u32, file_path: *const c_char, presentation_timestamp: f64) -> GifskiError { 222 | if file_path.is_null() { 223 | return GifskiError::NULL_ARG; 224 | } 225 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 226 | 227 | let path = if let Ok(s) = CStr::from_ptr(file_path).to_str() { 228 | PathBuf::from(s) 229 | } else { 230 | return GifskiError::INVALID_INPUT; 231 | }; 232 | if let Ok(Some(c)) = g.collector.lock().as_deref_mut() { 233 | c.add_frame_png_file(frame_number as usize, path, presentation_timestamp).into() 234 | } else { 235 | g.print_error(format!("frame {frame_number} can't be added any more, because gifski_end_adding_frames has been called already")); 236 | GifskiError::INVALID_STATE 237 | } 238 | } 239 | 240 | /// Pixels is an array width×height×4 bytes large. The array is copied, so you can free/reuse it immediately. 241 | /// 242 | /// Presentation timestamp (PTS) is time in seconds, since start of the file (at 0), when this frame is to be displayed. 243 | /// For a 20fps video it could be `frame_number/20.0`. 244 | /// Frames with duplicate or out-of-order PTS will be skipped. 245 | /// 246 | /// The first frame should have PTS=0. If the first frame has PTS > 0, it'll be used as a delay after the last frame. 247 | /// 248 | /// Colors are in sRGB, uncorrelated RGBA, with alpha byte last. 249 | /// 250 | /// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` or `gifski_set_file_output` first to avoid a deadlock. 251 | /// 252 | /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 253 | #[no_mangle] 254 | pub unsafe extern "C" fn gifski_add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, width: u32, height: u32, pixels: *const RGBA8, presentation_timestamp: f64) -> GifskiError { 255 | if pixels.is_null() { 256 | return GifskiError::NULL_ARG; 257 | } 258 | if width == 0 || height == 0 || width > 0xFFFF || height > 0xFFFF { 259 | return GifskiError::INVALID_INPUT; 260 | } 261 | let width = width as usize; 262 | let height = height as usize; 263 | let pixels = slice::from_raw_parts(pixels, width * height); 264 | add_frame_rgba(handle, frame_number, Img::new(pixels.into(), width, height), presentation_timestamp) 265 | } 266 | 267 | /// Same as `gifski_add_frame_rgba`, but with bytes per row arg. 268 | #[no_mangle] 269 | pub unsafe extern "C" fn gifski_add_frame_rgba_stride(handle: *const GifskiHandle, frame_number: u32, width: u32, height: u32, bytes_per_row: u32, pixels: *const RGBA8, presentation_timestamp: f64) -> GifskiError { 270 | let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) { 271 | Ok(v) => v, 272 | Err(err) => return err, 273 | }; 274 | let img = ImgVec::new_stride(pixels.into(), width as _, height as _, stride); 275 | add_frame_rgba(handle, frame_number, img, presentation_timestamp) 276 | } 277 | 278 | unsafe fn pixels_slice<'a, T>(pixels: *const T, width: u32, height: u32, bytes_per_row: u32) -> Result<(&'a [T], usize), GifskiError> { 279 | if pixels.is_null() { 280 | return Err(GifskiError::NULL_ARG); 281 | } 282 | let stride = bytes_per_row as usize / mem::size_of::(); 283 | let width = width as usize; 284 | let height = height as usize; 285 | if stride < width || width == 0 || height == 0 || width > 0xFFFF || height > 0xFFFF { 286 | return Err(GifskiError::INVALID_INPUT); 287 | } 288 | let pixels = slice::from_raw_parts(pixels, stride * height + width - stride); 289 | Ok((pixels, stride)) 290 | } 291 | 292 | fn add_frame_rgba(handle: *const GifskiHandle, frame_number: u32, frame: ImgVec, presentation_timestamp: f64) -> GifskiError { 293 | let Some(g) = (unsafe { borrow(handle) }) else { return GifskiError::NULL_ARG }; 294 | 295 | if let Ok(Some(c)) = g.collector.lock().as_deref_mut() { 296 | c.add_frame_rgba(frame_number as usize, frame, presentation_timestamp).into() 297 | } else { 298 | g.print_error(format!("frame {frame_number} can't be added any more, because gifski_end_adding_frames has been called already")); 299 | GifskiError::INVALID_STATE 300 | } 301 | } 302 | 303 | /// Same as `gifski_add_frame_rgba`, except it expects components in ARGB order. 304 | /// 305 | /// Bytes per row must be multiple of 4 and greater or equal width×4. 306 | /// 307 | /// Colors are in sRGB, uncorrelated ARGB, with alpha byte first. 308 | /// 309 | /// `gifski_add_frame_rgba` is preferred over this function. 310 | #[no_mangle] 311 | pub unsafe extern "C" fn gifski_add_frame_argb(handle: *const GifskiHandle, frame_number: u32, width: u32, bytes_per_row: u32, height: u32, pixels: *const ARGB8, presentation_timestamp: f64) -> GifskiError { 312 | let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) { 313 | Ok(v) => v, 314 | Err(err) => return err, 315 | }; 316 | let width = width as usize; 317 | let height = height as usize; 318 | let img = ImgVec::new(pixels.chunks(stride).flat_map(|r| r[0..width].iter().map(|p| RGBA8 { 319 | r: p.r, 320 | g: p.g, 321 | b: p.b, 322 | a: p.a, 323 | })).collect(), width, height); 324 | add_frame_rgba(handle, frame_number, img, presentation_timestamp) 325 | } 326 | 327 | /// Same as `gifski_add_frame_rgba`, except it expects RGB components (3 bytes per pixel). 328 | /// 329 | /// Bytes per row must be multiple of 3 and greater or equal width×3. 330 | /// 331 | /// Colors are in sRGB, red byte first. 332 | 333 | /// This function may block and wait until the frame is processed. Make sure to call `gifski_set_write_callback` first to avoid a deadlock. 334 | /// 335 | /// `gifski_add_frame_rgba` is preferred over this function. 336 | #[no_mangle] 337 | pub unsafe extern "C" fn gifski_add_frame_rgb(handle: *const GifskiHandle, frame_number: u32, width: u32, bytes_per_row: u32, height: u32, pixels: *const RGB8, presentation_timestamp: f64) -> GifskiError { 338 | let (pixels, stride) = match pixels_slice(pixels, width, height, bytes_per_row) { 339 | Ok(v) => v, 340 | Err(err) => return err, 341 | }; 342 | let width = width as usize; 343 | let height = height as usize; 344 | let img = ImgVec::new(pixels.chunks(stride).flat_map(|r| r[0..width].iter().map(|&p| p.with_alpha(255))).collect(), width, height); 345 | add_frame_rgba(handle, frame_number, img, presentation_timestamp) 346 | } 347 | 348 | /// Get a callback for frame processed, and abort processing if desired. 349 | /// 350 | /// The callback is called once per input frame, 351 | /// even if the encoder decides to skip some frames. 352 | /// 353 | /// It gets arbitrary pointer (`user_data`) as an argument. `user_data` can be `NULL`. 354 | /// 355 | /// The callback must return `1` to continue processing, or `0` to abort. 356 | /// 357 | /// The callback must be thread-safe (it will be called from another thread). 358 | /// It must remain valid at all times, until `gifski_finish` completes. 359 | /// 360 | /// This function must be called before `gifski_set_file_output()` to take effect. 361 | #[no_mangle] 362 | pub unsafe extern "C" fn gifski_set_progress_callback(handle: *const GifskiHandle, cb: unsafe extern "C" fn(*mut c_void) -> c_int, user_data: *mut c_void) -> GifskiError { 363 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 364 | 365 | if g.write_thread.lock().map_or(true, |t| t.0) { 366 | g.print_error("tried to set progress callback after writing has already started".into()); 367 | return GifskiError::INVALID_STATE; 368 | } 369 | match g.progress.lock() { 370 | Ok(mut progress) => { 371 | *progress = Some(ProgressCallback::new(cb, user_data)); 372 | GifskiError::OK 373 | }, 374 | Err(_) => GifskiError::THREAD_LOST, 375 | } 376 | } 377 | 378 | /// Get a callback when an error occurs. 379 | /// This is intended mostly for logging and debugging, not for user interface. 380 | /// 381 | /// The callback function has the following arguments: 382 | /// * A `\0`-terminated C string in UTF-8 encoding. The string is only valid for the duration of the call. Make a copy if you need to keep it. 383 | /// * An arbitrary pointer (`user_data`). `user_data` can be `NULL`. 384 | /// 385 | /// The callback must be thread-safe (it will be called from another thread). 386 | /// It must remain valid at all times, until `gifski_finish` completes. 387 | /// 388 | /// If the callback is not set, errors will be printed to stderr. 389 | /// 390 | /// This function must be called before `gifski_set_file_output()` to take effect. 391 | #[no_mangle] 392 | pub unsafe extern "C" fn gifski_set_error_message_callback(handle: *const GifskiHandle, cb: unsafe extern "C" fn(*const c_char, *mut c_void), user_data: *mut c_void) -> GifskiError { 393 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 394 | 395 | let user_data = SendableUserData(user_data); 396 | match g.error_callback.lock() { 397 | Ok(mut error_callback) => { 398 | *error_callback = Some(Box::new(move |mut s: String| { 399 | s.reserve_exact(1); 400 | s.push('\0'); 401 | let cstring = CString::from_vec_with_nul(s.into_bytes()).unwrap_or_default(); 402 | unsafe { cb(cstring.as_ptr(), user_data.clone().0) } // the clone is a no-op, only to force closure to own it 403 | })); 404 | GifskiError::OK 405 | }, 406 | Err(_) => GifskiError::THREAD_LOST, 407 | } 408 | } 409 | 410 | #[derive(Clone)] 411 | struct SendableUserData(*mut c_void); 412 | unsafe impl Send for SendableUserData {} 413 | unsafe impl Sync for SendableUserData {} 414 | 415 | /// Start writing to the `destination`. This has to be called before any frames are added. 416 | /// 417 | /// This call will not block. 418 | /// 419 | /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 420 | #[no_mangle] 421 | pub unsafe extern "C" fn gifski_set_file_output(handle: *const GifskiHandle, destination: *const c_char) -> GifskiError { 422 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 423 | catch_unwind(move || { 424 | let (file, path) = match prepare_for_file_writing(g, destination) { 425 | Ok(res) => res, 426 | Err(err) => return err, 427 | }; 428 | gifski_write_thread_start(g, file, Some(path)).err().unwrap_or(GifskiError::OK) 429 | }) 430 | .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) 431 | } 432 | 433 | fn prepare_for_file_writing(g: &GifskiHandleInternal, destination: *const c_char) -> Result<(File, PathBuf), GifskiError> { 434 | if destination.is_null() { 435 | return Err(GifskiError::NULL_ARG); 436 | } 437 | let path = if let Ok(s) = unsafe { CStr::from_ptr(destination).to_str() } { 438 | Path::new(s) 439 | } else { 440 | return Err(GifskiError::INVALID_INPUT); 441 | }; 442 | let t = g.write_thread.lock().map_err(|_| GifskiError::THREAD_LOST)?; 443 | if t.0 { 444 | g.print_error("tried to start writing for the second time, after it has already started".into()); 445 | return Err(GifskiError::INVALID_STATE); 446 | } 447 | match File::create(path) { 448 | Ok(file) => Ok((file, path.into())), 449 | Err(err) => Err(err.kind().into()), 450 | } 451 | } 452 | 453 | struct CallbackWriter { 454 | cb: unsafe extern "C" fn(usize, *const u8, *mut c_void) -> c_int, 455 | user_data: *mut c_void, 456 | } 457 | 458 | unsafe impl Send for CallbackWriter {} 459 | 460 | impl io::Write for CallbackWriter { 461 | fn write(&mut self, buf: &[u8]) -> io::Result { 462 | match unsafe { (self.cb)(buf.len(), buf.as_ptr(), self.user_data) } { 463 | 0 => Ok(buf.len()), 464 | x => Err(GifskiError::from(x).into()), 465 | } 466 | } 467 | 468 | fn flush(&mut self) -> io::Result<()> { 469 | match unsafe { (self.cb)(0, ptr::null(), self.user_data) } { 470 | 0 => Ok(()), 471 | x => Err(GifskiError::from(x).into()), 472 | } 473 | } 474 | } 475 | 476 | /// Start writing via callback (any buffer, file, whatever you want). This has to be called before any frames are added. 477 | /// This call will not block. 478 | /// 479 | /// The callback function receives 3 arguments: 480 | /// - size of the buffer to write, in bytes. IT MAY BE ZERO (when it's zero, either do nothing, or flush internal buffers if necessary). 481 | /// - pointer to the buffer. 482 | /// - context pointer to arbitrary user data, same as passed in to this function. 483 | /// 484 | /// The callback should return 0 (`GIFSKI_OK`) on success, and non-zero on error. 485 | /// 486 | /// The callback function must be thread-safe. It must remain valid at all times, until `gifski_finish` completes. 487 | /// 488 | /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 489 | #[no_mangle] 490 | pub unsafe extern "C" fn gifski_set_write_callback(handle: *const GifskiHandle, cb: Option c_int>, user_data: *mut c_void) -> GifskiError { 491 | let Some(g) = borrow(handle) else { return GifskiError::NULL_ARG }; 492 | catch_unwind(move || { 493 | let Some(cb) = cb else { return GifskiError::NULL_ARG }; 494 | 495 | let writer = CallbackWriter { cb, user_data }; 496 | gifski_write_thread_start(g, writer, None).err().unwrap_or(GifskiError::OK) 497 | }) 498 | .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) 499 | } 500 | 501 | fn gifski_write_thread_start(g: &GifskiHandleInternal, file: W, path: Option) -> Result<(), GifskiError> { 502 | let mut t = g.write_thread.lock().map_err(|_| GifskiError::THREAD_LOST)?; 503 | if t.0 { 504 | g.print_error("gifski_set_file_output/gifski_set_write_callback has been called already".into()); 505 | return Err(GifskiError::INVALID_STATE); 506 | } 507 | let writer = g.writer.lock().map_err(|_| GifskiError::THREAD_LOST)?.take(); 508 | let mut user_progress = g.progress.lock().map_err(|_| GifskiError::THREAD_LOST)?.take(); 509 | let handle = thread::Builder::new().name("c-write".into()).spawn(move || { 510 | if let Some(writer) = writer { 511 | let progress = user_progress.as_mut().map(|m| m as &mut dyn ProgressReporter); 512 | match writer.write(file, progress.unwrap_or(&mut NoProgress {})).into() { 513 | res @ (GifskiError::OK | GifskiError::ALREADY_EXISTS) => res, 514 | err => { 515 | if let Some(path) = path { 516 | let _ = fs::remove_file(path); // clean up unfinished file 517 | } 518 | err 519 | }, 520 | } 521 | } else { 522 | eprintln!("gifski_set_file_output/gifski_set_write_callback has been called already"); 523 | GifskiError::INVALID_STATE 524 | } 525 | }); 526 | match handle { 527 | Ok(handle) => { 528 | *t = (true, Some(handle)); 529 | Ok(()) 530 | }, 531 | Err(_) => Err(GifskiError::THREAD_LOST), 532 | } 533 | } 534 | 535 | unsafe fn borrow<'a>(handle: *const GifskiHandle) -> Option<&'a GifskiHandleInternal> { 536 | let g = handle.cast::(); 537 | g.as_ref() 538 | } 539 | 540 | /// The last step: 541 | /// - stops accepting any more frames (`gifski_add_frame_*` calls are blocked) 542 | /// - blocks and waits until all already-added frames have finished writing 543 | /// 544 | /// Returns final status of write operations. Remember to check the return value! 545 | /// 546 | /// Must always be called, otherwise it will leak memory. 547 | /// After this call, the handle is freed and can't be used any more. 548 | /// 549 | /// Returns 0 (`GIFSKI_OK`) on success, and non-0 `GIFSKI_*` constant on error. 550 | #[no_mangle] 551 | pub unsafe extern "C" fn gifski_finish(g: *const GifskiHandle) -> GifskiError { 552 | if g.is_null() { 553 | return GifskiError::NULL_ARG; 554 | } 555 | let g = Arc::from_raw(g.cast::()); 556 | catch_unwind(|| { 557 | match g.collector.lock() { 558 | // dropping of the collector (if any) completes writing 559 | Ok(mut lock) => *lock = None, 560 | Err(_) => { 561 | g.print_error("warning: collector thread crashed".into()); 562 | }, 563 | } 564 | 565 | let thread = match g.write_thread.lock() { 566 | Ok(mut writer) => writer.1.take(), 567 | Err(_) => return GifskiError::THREAD_LOST, 568 | }; 569 | 570 | if let Some(thread) = thread { 571 | thread.join().map_err(|e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) 572 | } else { 573 | g.print_error("warning: gifski_finish called before any output has been set".into()); 574 | GifskiError::OK // this will become INVALID_STATE once sync write support is dropped 575 | } 576 | }) 577 | .map_err(move |e| g.print_panic(e)).unwrap_or(GifskiError::THREAD_LOST) 578 | } 579 | 580 | impl GifskiHandleInternal { 581 | fn print_error(&self, mut err: String) { 582 | if let Ok(Some(cb)) = self.error_callback.lock().as_deref() { 583 | cb(err); 584 | } else { 585 | err.reserve_exact(1); 586 | err.push('\n'); 587 | let _ = std::io::stderr().write_all(err.as_bytes()); 588 | } 589 | } 590 | 591 | fn print_panic(&self, e: Box) { 592 | let msg = e.downcast_ref::().map(|s| s.as_str()) 593 | .or_else(|| e.downcast_ref::<&str>().copied()).unwrap_or("unknown panic"); 594 | self.print_error(format!("writer crashed (this is a bug): {msg}")); 595 | } 596 | } 597 | 598 | #[test] 599 | fn c_cb() { 600 | use rgb::RGB; 601 | let g = unsafe { 602 | gifski_new(&GifskiSettings { 603 | width: 1, 604 | height: 1, 605 | quality: 100, 606 | fast: false, 607 | repeat: -1, 608 | }) 609 | }; 610 | assert!(!g.is_null()); 611 | let mut write_called = false; 612 | unsafe extern "C" fn cb(_s: usize, _buf: *const u8, user_data: *mut c_void) -> c_int { 613 | let write_called = user_data.cast::(); 614 | *write_called = true; 615 | 0 616 | } 617 | let mut progress_called = 0u32; 618 | unsafe extern "C" fn pcb(user_data: *mut c_void) -> c_int { 619 | let progress_called = user_data.cast::(); 620 | *progress_called += 1; 621 | 1 622 | } 623 | unsafe { 624 | assert_eq!(GifskiError::OK, gifski_set_progress_callback(g, pcb, ptr::addr_of_mut!(progress_called).cast())); 625 | assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::addr_of_mut!(write_called).cast())); 626 | assert_eq!(GifskiError::INVALID_STATE, gifski_set_progress_callback(g, pcb, ptr::addr_of_mut!(progress_called).cast())); 627 | assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0,0,0), 3.)); 628 | assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0,0,0), 10.)); 629 | assert_eq!(GifskiError::OK, gifski_finish(g)); 630 | } 631 | assert!(write_called); 632 | assert_eq!(2, progress_called); 633 | } 634 | 635 | #[test] 636 | fn progress_abort() { 637 | use rgb::RGB; 638 | let g = unsafe { 639 | gifski_new(&GifskiSettings { 640 | width: 1, 641 | height: 1, 642 | quality: 100, 643 | fast: false, 644 | repeat: -1, 645 | }) 646 | }; 647 | assert!(!g.is_null()); 648 | unsafe extern "C" fn cb(_size: usize, _buf: *const u8, _user_data: *mut c_void) -> c_int { 649 | 0 650 | } 651 | unsafe extern "C" fn pcb(_user_data: *mut c_void) -> c_int { 652 | 0 653 | } 654 | unsafe { 655 | assert_eq!(GifskiError::OK, gifski_set_progress_callback(g, pcb, ptr::null_mut())); 656 | assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); 657 | assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 3.)); 658 | assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 10.)); 659 | assert_eq!(GifskiError::ABORTED, gifski_finish(g)); 660 | } 661 | } 662 | 663 | #[test] 664 | fn cant_write_after_finish() { 665 | let g = unsafe { gifski_new(&GifskiSettings { 666 | width: 1, height: 1, 667 | quality: 100, 668 | fast: false, 669 | repeat: -1, 670 | })}; 671 | assert!(!g.is_null()); 672 | unsafe extern "C" fn cb(_s: usize, _buf: *const u8, u1: *mut c_void) -> c_int { 673 | assert_eq!(u1 as usize, 1); 674 | 0 675 | } 676 | unsafe { 677 | assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), 1 as _)); 678 | assert_eq!(GifskiError::INVALID_STATE, gifski_finish(g)); 679 | } 680 | } 681 | 682 | #[test] 683 | fn c_write_failure_propagated() { 684 | use rgb::RGB; 685 | let g = unsafe { gifski_new(&GifskiSettings { 686 | width: 1, height: 1, 687 | quality: 100, 688 | fast: false, 689 | repeat: -1, 690 | })}; 691 | assert!(!g.is_null()); 692 | unsafe extern "C" fn cb(_s: usize, _buf: *const u8, _user: *mut c_void) -> c_int { 693 | GifskiError::WRITE_ZERO as c_int 694 | } 695 | unsafe { 696 | assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); 697 | assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 0, 1, 3, 1, &RGB::new(0, 0, 0), 5.0)); 698 | assert_eq!(GifskiError::WRITE_ZERO, gifski_finish(g)); 699 | } 700 | } 701 | 702 | #[test] 703 | fn test_error_callback() { 704 | let g = unsafe { gifski_new(&GifskiSettings { 705 | width: 1, height: 1, 706 | quality: 100, 707 | fast: false, 708 | repeat: -1, 709 | })}; 710 | assert!(!g.is_null()); 711 | unsafe extern "C" fn cb(_s: usize, _buf: *const u8, u1: *mut c_void) -> c_int { 712 | assert_eq!(u1 as usize, 1); 713 | 0 714 | } 715 | unsafe extern "C" fn errcb(msg: *const c_char, user_data: *mut c_void) { 716 | let callback_msg = user_data.cast::>(); 717 | *callback_msg = Some(CStr::from_ptr(msg).to_str().unwrap().to_string()); 718 | } 719 | let mut callback_msg: Option = None; 720 | unsafe { 721 | assert_eq!(GifskiError::OK, gifski_set_error_message_callback(g, errcb, std::ptr::addr_of_mut!(callback_msg) as _)); 722 | assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), 1 as _)); 723 | assert_eq!(GifskiError::INVALID_STATE, gifski_set_write_callback(g, Some(cb), 1 as _)); 724 | assert_eq!(GifskiError::INVALID_STATE, gifski_finish(g)); 725 | assert_eq!("gifski_set_file_output/gifski_set_write_callback has been called already", callback_msg.unwrap()); 726 | } 727 | } 728 | 729 | #[test] 730 | fn cant_write_twice() { 731 | let g = unsafe { gifski_new(&GifskiSettings { 732 | width: 1, height: 1, 733 | quality: 100, 734 | fast: false, 735 | repeat: -1, 736 | })}; 737 | assert!(!g.is_null()); 738 | unsafe extern "C" fn cb(_s: usize, _buf: *const u8, _user: *mut c_void) -> c_int { 739 | GifskiError::WRITE_ZERO as c_int 740 | } 741 | unsafe { 742 | assert_eq!(GifskiError::OK, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); 743 | assert_eq!(GifskiError::INVALID_STATE, gifski_set_write_callback(g, Some(cb), ptr::null_mut())); 744 | } 745 | } 746 | 747 | #[test] 748 | fn c_incomplete() { 749 | use rgb::RGB; 750 | let g = unsafe { gifski_new(&GifskiSettings { 751 | width: 0, height: 0, 752 | quality: 100, 753 | fast: true, 754 | repeat: 0, 755 | })}; 756 | assert_eq!(3, mem::size_of::()); 757 | 758 | assert!(!g.is_null()); 759 | unsafe { 760 | assert_eq!(GifskiError::NULL_ARG, gifski_add_frame_rgba(g, 0, 1, 1, ptr::null(), 5.0)); 761 | } 762 | extern "C" fn cb(_: *mut c_void) -> c_int { 763 | 1 764 | } 765 | unsafe { 766 | gifski_set_progress_callback(g, cb, ptr::null_mut()); 767 | assert_eq!(GifskiError::OK, gifski_add_frame_rgba(g, 0, 1, 1, &RGBA8::new(0, 0, 0, 0), 5.0)); 768 | assert_eq!(GifskiError::OK, gifski_add_frame_rgb(g, 1, 1, 3, 1, &RGB::new(0, 0, 0), 5.0)); 769 | assert_eq!(GifskiError::OK, gifski_finish(g)); 770 | } 771 | } 772 | -------------------------------------------------------------------------------- /src/c_api/c_api_error.rs: -------------------------------------------------------------------------------- 1 | use crate::GifResult; 2 | use std::fmt; 3 | use std::io; 4 | use std::os::raw::c_int; 5 | 6 | #[repr(C)] 7 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 8 | #[allow(non_camel_case_types)] 9 | #[allow(clippy::upper_case_acronyms)] 10 | pub enum GifskiError { 11 | OK = 0, 12 | NULL_ARG, 13 | INVALID_STATE, 14 | QUANT, 15 | GIF, 16 | THREAD_LOST, 17 | NOT_FOUND, 18 | PERMISSION_DENIED, 19 | ALREADY_EXISTS, 20 | INVALID_INPUT, 21 | TIMED_OUT, 22 | WRITE_ZERO, 23 | INTERRUPTED, 24 | UNEXPECTED_EOF, 25 | ABORTED, 26 | OTHER, 27 | } 28 | 29 | impl From for io::Error { 30 | #[cold] 31 | fn from(g: GifskiError) -> Self { 32 | use std::io::ErrorKind as EK; 33 | use GifskiError::*; 34 | match g { 35 | OK => panic!("wrong err code"), 36 | NOT_FOUND => EK::NotFound, 37 | PERMISSION_DENIED => EK::PermissionDenied, 38 | ALREADY_EXISTS => EK::AlreadyExists, 39 | INVALID_INPUT => EK::InvalidInput, 40 | TIMED_OUT => EK::TimedOut, 41 | WRITE_ZERO => EK::WriteZero, 42 | INTERRUPTED => EK::Interrupted, 43 | UNEXPECTED_EOF => EK::UnexpectedEof, 44 | _ => return Self::other(g), 45 | }.into() 46 | } 47 | } 48 | 49 | impl From for GifskiError { 50 | #[cold] 51 | fn from(res: c_int) -> Self { 52 | use GifskiError::*; 53 | match res { 54 | x if x == OK as c_int => OK, 55 | x if x == NULL_ARG as c_int => NULL_ARG, 56 | x if x == INVALID_STATE as c_int => INVALID_STATE, 57 | x if x == QUANT as c_int => QUANT, 58 | x if x == GIF as c_int => GIF, 59 | x if x == THREAD_LOST as c_int => THREAD_LOST, 60 | x if x == NOT_FOUND as c_int => NOT_FOUND, 61 | x if x == PERMISSION_DENIED as c_int => PERMISSION_DENIED, 62 | x if x == ALREADY_EXISTS as c_int => ALREADY_EXISTS, 63 | x if x == INVALID_INPUT as c_int => INVALID_INPUT, 64 | x if x == TIMED_OUT as c_int => TIMED_OUT, 65 | x if x == WRITE_ZERO as c_int => WRITE_ZERO, 66 | x if x == INTERRUPTED as c_int => INTERRUPTED, 67 | x if x == UNEXPECTED_EOF as c_int => UNEXPECTED_EOF, 68 | x if x == ABORTED as c_int => ABORTED, 69 | _ => OTHER, 70 | } 71 | } 72 | } 73 | 74 | impl From> for GifskiError { 75 | #[cold] 76 | fn from(res: GifResult<()>) -> Self { 77 | use crate::error::Error::*; 78 | match res { 79 | Ok(()) => GifskiError::OK, 80 | Err(err) => match err { 81 | Quant(_) => GifskiError::QUANT, 82 | Pal(_) => GifskiError::GIF, 83 | ThreadSend => GifskiError::THREAD_LOST, 84 | Io(ref err) => err.kind().into(), 85 | Aborted => GifskiError::ABORTED, 86 | Gifsicle | Gif(_) => GifskiError::GIF, 87 | NoFrames => GifskiError::INVALID_STATE, 88 | WrongSize(_) => GifskiError::INVALID_INPUT, 89 | PNG(_) => GifskiError::OTHER, 90 | }, 91 | } 92 | } 93 | } 94 | 95 | impl From for GifskiError { 96 | #[cold] 97 | fn from(res: io::ErrorKind) -> Self { 98 | use std::io::ErrorKind as EK; 99 | match res { 100 | EK::NotFound => GifskiError::NOT_FOUND, 101 | EK::PermissionDenied => GifskiError::PERMISSION_DENIED, 102 | EK::AlreadyExists => GifskiError::ALREADY_EXISTS, 103 | EK::InvalidInput | EK::InvalidData => GifskiError::INVALID_INPUT, 104 | EK::TimedOut => GifskiError::TIMED_OUT, 105 | EK::WriteZero => GifskiError::WRITE_ZERO, 106 | EK::Interrupted => GifskiError::INTERRUPTED, 107 | EK::UnexpectedEof => GifskiError::UNEXPECTED_EOF, 108 | _ => GifskiError::OTHER, 109 | } 110 | } 111 | } 112 | 113 | impl std::error::Error for GifskiError {} 114 | 115 | impl fmt::Display for GifskiError { 116 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 117 | fmt::Debug::fmt(self, f) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/collector.rs: -------------------------------------------------------------------------------- 1 | //! For adding frames to the encoder 2 | //! 3 | //! [`gifski::new()`][crate::new] returns the [`Collector`] that collects animation frames, 4 | //! and a [`Writer`][crate::Writer] that performs compression and I/O. 5 | 6 | pub use imgref::ImgVec; 7 | pub use rgb::{RGB8, RGBA8}; 8 | 9 | use crate::error::GifResult; 10 | use crossbeam_channel::Sender; 11 | 12 | #[cfg(feature = "png")] 13 | use std::path::PathBuf; 14 | 15 | pub(crate) enum FrameSource { 16 | Pixels(ImgVec), 17 | #[cfg(feature = "png")] 18 | PngData(Vec), 19 | #[cfg(all(feature = "png", not(target_arch = "wasm32")))] 20 | Path(PathBuf), 21 | } 22 | 23 | pub(crate) struct InputFrame { 24 | /// The pixels to resize and encode 25 | pub frame: FrameSource, 26 | /// Time in seconds when to display the frame. First frame should start at 0. 27 | pub presentation_timestamp: f64, 28 | pub frame_index: usize, 29 | } 30 | 31 | pub(crate) struct InputFrameResized { 32 | /// The pixels to encode 33 | pub frame: ImgVec, 34 | /// The same as above, but with smart blur applied (for denoiser) 35 | pub frame_blurred: ImgVec, 36 | /// Time in seconds when to display the frame. First frame should start at 0. 37 | pub presentation_timestamp: f64, 38 | } 39 | 40 | /// Collect frames that will be encoded 41 | /// 42 | /// Note that writing will finish only when the collector is dropped. 43 | /// Collect frames on another thread, or call `drop(collector)` before calling `writer.write()`! 44 | pub struct Collector { 45 | pub(crate) queue: Sender, 46 | } 47 | 48 | impl Collector { 49 | /// Frame index starts at 0. 50 | /// 51 | /// Set each frame (index) only once, but you can set them in any order. However, out-of-order frames 52 | /// will be buffered in RAM, and big gaps in frame indices will cause high memory usage. 53 | /// 54 | /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed. 55 | /// 56 | /// If the first frame doesn't start at pts=0, the delay will be used for the last frame. 57 | /// 58 | /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running. 59 | #[cfg_attr(debug_assertions, track_caller)] 60 | pub fn add_frame_rgba(&self, frame_index: usize, frame: ImgVec, presentation_timestamp: f64) -> GifResult<()> { 61 | debug_assert!(frame_index == 0 || presentation_timestamp > 0.); 62 | self.queue.send(InputFrame { 63 | frame_index, 64 | frame: FrameSource::Pixels(frame), 65 | presentation_timestamp, 66 | })?; 67 | Ok(()) 68 | } 69 | 70 | /// Decode a frame from in-memory PNG-compressed data. 71 | /// 72 | /// Frame index starts at 0. 73 | /// Set each frame (index) only once, but you can set them in any order. However, out-of-order frames 74 | /// will be buffered in RAM, and big gaps in frame indices will cause high memory usage. 75 | /// 76 | /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed. 77 | /// 78 | /// If the first frame doesn't start at pts=0, the delay will be used for the last frame. 79 | /// 80 | /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running. 81 | #[cfg(feature = "png")] 82 | #[inline] 83 | pub fn add_frame_png_data(&self, frame_index: usize, png_data: Vec, presentation_timestamp: f64) -> GifResult<()> { 84 | self.queue.send(InputFrame { 85 | frame: FrameSource::PngData(png_data), 86 | presentation_timestamp, 87 | frame_index, 88 | })?; 89 | Ok(()) 90 | } 91 | 92 | /// Read and decode a PNG file from disk. 93 | /// 94 | /// Frame index starts at 0. 95 | /// Set each frame (index) only once, but you can set them in any order. 96 | /// 97 | /// Presentation timestamp is time in seconds (since file start at 0) when this frame is to be displayed. 98 | /// 99 | /// If the first frame doesn't start at pts=0, the delay will be used for the last frame. 100 | /// 101 | /// If this function appears to be stuck after a few frames, it's because [`crate::Writer::write()`] is not running. 102 | #[cfg(feature = "png")] 103 | pub fn add_frame_png_file(&self, frame_index: usize, path: PathBuf, presentation_timestamp: f64) -> GifResult<()> { 104 | self.queue.send(InputFrame { 105 | frame: FrameSource::Path(path), 106 | presentation_timestamp, 107 | frame_index, 108 | })?; 109 | Ok(()) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/denoise.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use crate::PushInCapacity; 3 | pub use imgref::ImgRef; 4 | use imgref::ImgVec; 5 | use loop9::loop9_img; 6 | use rgb::ComponentMap; 7 | use rgb::RGB8; 8 | pub use rgb::RGBA8; 9 | 10 | const LOOKAHEAD: usize = 5; 11 | 12 | #[derive(Debug, Default, Copy, Clone)] 13 | pub struct Acc { 14 | px_blur: [(RGB8, RGB8); LOOKAHEAD], 15 | alpha_bits: u8, 16 | can_stay_for: u8, 17 | stayed_for: u8, 18 | /// The last pixel used (currently on screen) 19 | bg_set: RGBA8, 20 | } 21 | 22 | impl Acc { 23 | /// Actual pixel + blurred pixel 24 | #[inline(always)] 25 | pub fn get(&self, idx: usize) -> Option<(RGB8, RGB8)> { 26 | if idx >= LOOKAHEAD { 27 | debug_assert!(idx < LOOKAHEAD); 28 | return None; 29 | } 30 | if self.alpha_bits & (1 << idx) == 0 { 31 | Some(self.px_blur[idx]) 32 | } else { 33 | None 34 | } 35 | } 36 | 37 | #[inline(always)] 38 | pub fn append(&mut self, val: RGBA8, val_blur: RGB8) { 39 | for n in 1..LOOKAHEAD { 40 | self.px_blur[n - 1] = self.px_blur[n]; 41 | } 42 | self.alpha_bits >>= 1; 43 | 44 | if val.a < 128 { 45 | self.alpha_bits |= 1 << (LOOKAHEAD - 1); 46 | } else { 47 | self.px_blur[LOOKAHEAD - 1] = (val.rgb(), val_blur); 48 | } 49 | } 50 | } 51 | 52 | pub enum Denoised { 53 | // Feed more frames 54 | NotYet, 55 | // No more 56 | Done, 57 | Frame { 58 | frame: ImgVec, 59 | importance_map: ImgVec, 60 | meta: T, 61 | }, 62 | } 63 | 64 | pub struct Denoiser { 65 | /// the algo starts outputting on 3rd frame 66 | frames: usize, 67 | threshold: u32, 68 | splat: ImgVec, 69 | processed: VecDeque<(ImgVec, ImgVec)>, 70 | metadatas: VecDeque, 71 | } 72 | 73 | #[derive(Debug)] 74 | pub struct WrongSizeError; 75 | 76 | impl Denoiser { 77 | #[inline] 78 | pub fn new(width: usize, height: usize, quality: u8) -> Result { 79 | let area = width.checked_mul(height).ok_or(WrongSizeError)?; 80 | let clear = Acc { 81 | px_blur: [(RGB8::new(0, 0, 0), RGB8::new(0, 0, 0)); LOOKAHEAD], 82 | alpha_bits: (1 << LOOKAHEAD) - 1, 83 | bg_set: RGBA8::default(), 84 | stayed_for: 0, 85 | can_stay_for: 0, 86 | }; 87 | Ok(Self { 88 | frames: 0, 89 | processed: VecDeque::with_capacity(LOOKAHEAD), 90 | metadatas: VecDeque::with_capacity(LOOKAHEAD), 91 | threshold: (55 - u32::from(quality) / 2).pow(2), 92 | splat: ImgVec::new(vec![clear; area], width, height), 93 | }) 94 | } 95 | 96 | fn quick_append(&mut self, frame: ImgRef, frame_blurred: ImgRef) { 97 | for ((acc, src), src_blur) in self.splat.pixels_mut().zip(frame.pixels()).zip(frame_blurred.pixels()) { 98 | acc.append(src, src_blur); 99 | } 100 | } 101 | 102 | /// Generate last few frames 103 | #[inline(never)] 104 | pub fn flush(&mut self) { 105 | while self.processed.len() < self.metadatas.len() { 106 | let mut median1 = Vec::with_capacity(self.splat.width() * self.splat.height()); 107 | let mut imp_map1 = Vec::with_capacity(self.splat.width() * self.splat.height()); 108 | 109 | let odd_frame = self.frames & 1 != 0; 110 | for acc in self.splat.pixels_mut() { 111 | acc.append(RGBA8::new(0, 0, 0, 0), RGB8::new(0, 0, 0)); 112 | let (m, i) = acc.next_pixel(self.threshold, odd_frame); 113 | median1.push_in_cap(m); 114 | imp_map1.push_in_cap(i); 115 | } 116 | 117 | // may need to push down first if there were not enough frames to fill the pipeline 118 | self.frames += 1; 119 | if self.frames >= LOOKAHEAD { 120 | let median1 = ImgVec::new(median1, self.splat.width(), self.splat.height()); 121 | let imp_map1 = ImgVec::new(imp_map1, self.splat.width(), self.splat.height()); 122 | self.processed.push_front((median1, imp_map1)); 123 | } 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | fn push_frame_test(&mut self, frame: ImgRef, frame_metadata: T) -> Result<(), WrongSizeError> { 129 | let frame_blurred = smart_blur(frame); 130 | self.push_frame(frame, frame_blurred.as_ref(), frame_metadata) 131 | } 132 | 133 | #[inline(never)] 134 | pub fn push_frame(&mut self, frame: ImgRef, frame_blurred: ImgRef, frame_metadata: T) -> Result<(), WrongSizeError> { 135 | if frame.width() != self.splat.width() || frame.height() != self.splat.height() { 136 | return Err(WrongSizeError); 137 | } 138 | 139 | self.metadatas.push_front(frame_metadata); 140 | 141 | self.frames += 1; 142 | // Can't output anything yet 143 | if self.frames < LOOKAHEAD { 144 | self.quick_append(frame, frame_blurred); 145 | return Ok(()); 146 | } 147 | 148 | let mut median = Vec::with_capacity(frame.width() * frame.height()); 149 | let mut imp_map = Vec::with_capacity(frame.width() * frame.height()); 150 | let odd_frame = self.frames & 1 != 0; 151 | for ((acc, src), src_blur) in self.splat.pixels_mut().zip(frame.pixels()).zip(frame_blurred.pixels()) { 152 | acc.append(src, src_blur); 153 | 154 | let (m, i) = acc.next_pixel(self.threshold, odd_frame); 155 | median.push_in_cap(m); 156 | imp_map.push_in_cap(i); 157 | } 158 | 159 | let median = ImgVec::new(median, frame.width(), frame.height()); 160 | let imp_map = ImgVec::new(imp_map, frame.width(), frame.height()); 161 | self.processed.push_front((median, imp_map)); 162 | Ok(()) 163 | } 164 | 165 | #[inline] 166 | pub fn pop(&mut self) -> Denoised { 167 | if let Some((frame, importance_map)) = self.processed.pop_back() { 168 | let meta = self.metadatas.pop_back().expect("meta"); 169 | Denoised::Frame { frame, importance_map, meta } 170 | } else if !self.metadatas.is_empty() { 171 | Denoised::NotYet 172 | } else { 173 | Denoised::Done 174 | } 175 | } 176 | } 177 | 178 | impl Acc { 179 | fn next_pixel(&mut self, threshold: u32, odd_frame: bool) -> (RGBA8, u8) { 180 | // No previous bg set, so find a new one 181 | if let Some((curr, curr_blur)) = self.get(0) { 182 | let my_turn = cohort(curr) != odd_frame; 183 | let threshold = if my_turn { threshold } else { threshold * 2 }; 184 | let diff_with_bg = if self.bg_set.a > 0 { 185 | let bg = color_diff(self.bg_set.rgb(), curr); 186 | let bg_blur = color_diff(self.bg_set.rgb(), curr_blur); 187 | if bg < bg_blur { bg } else { (bg + bg_blur) / 2 } 188 | } else { 1<<20 }; 189 | 190 | if self.stayed_for < self.can_stay_for { 191 | // If this is the second, corrective frame, then 192 | // give it weight proportional to its staying duration 193 | let max = if self.stayed_for > 0 { 0 } else { 194 | [0, 40, 80, 100, 110][self.can_stay_for.min(4) as usize] 195 | }; 196 | // min == 0 may wipe pixels totally clear, so give them at least a second chance, 197 | // if quality setting allows 198 | let min = match threshold { 199 | 0..300 if self.stayed_for < 3 => 1, // q >= 75 200 | 300..500 if self.stayed_for < 2 => 1, 201 | 400..900 if self.stayed_for < 1 => 1, // q >= 50 202 | _ => 0, 203 | }; 204 | self.stayed_for += 1; 205 | return (self.bg_set, pixel_importance(diff_with_bg, threshold, min, max)); 206 | } 207 | 208 | // if it's still good, keep rolling with it 209 | if diff_with_bg < threshold { 210 | return (self.bg_set, 0); 211 | } 212 | 213 | // See how long this bg can stay 214 | let mut stays_frames = 0; 215 | for i in 1..LOOKAHEAD { 216 | if self.get(i).is_some_and(|(c, blurred)| color_diff(c, curr) < threshold || color_diff(blurred, curr_blur) < threshold) { 217 | stays_frames = i; 218 | } else { 219 | break; 220 | } 221 | } 222 | 223 | // fast path for regular changing pixel 224 | if stays_frames == 0 { 225 | self.bg_set = curr.with_alpha(255); 226 | return (self.bg_set, pixel_importance(diff_with_bg, threshold, 10, 110)); 227 | } 228 | let imp = if stays_frames <= 1 { 229 | pixel_importance(diff_with_bg, threshold, 5, 80) 230 | } else if stays_frames == 2 { 231 | pixel_importance(diff_with_bg, threshold, 15, 190) 232 | } else { 233 | pixel_importance(diff_with_bg, threshold, 50, 205) 234 | }; 235 | 236 | // set the new current (bg) color to the median of the frames it matches 237 | self.bg_set = get_medians(&self.px_blur, stays_frames).with_alpha(255); 238 | // shorten stay-for to use overlapping ranges for smoother transitions 239 | self.can_stay_for = (stays_frames as u8).min(LOOKAHEAD as u8 - 1); 240 | self.stayed_for = 0; 241 | (self.bg_set, imp) 242 | } else { 243 | // pixels with importance == 0 are totally ignored, but that could skip frames 244 | // which need to set background to clear 245 | let imp = if self.bg_set.a > 0 { 246 | self.bg_set.a = 0; 247 | self.can_stay_for = 0; 248 | 1 249 | } else { 0 }; 250 | (RGBA8::new(0,0,0,0), imp) 251 | } 252 | } 253 | } 254 | 255 | /// Median of 9 neighboring pixels 256 | macro_rules! median_channel { 257 | ($top:expr, $mid:expr, $bot:expr, $chan:ident) => { 258 | *[ 259 | if $top.prev.a > 0 { $top.prev.$chan } else { $mid.curr.$chan }, 260 | if $top.curr.a > 0 { $top.curr.$chan } else { $mid.curr.$chan }, 261 | if $top.next.a > 0 { $top.next.$chan } else { $mid.curr.$chan }, 262 | if $mid.prev.a > 0 { $mid.prev.$chan } else { $mid.curr.$chan }, 263 | $mid.curr.$chan, // if the center pixel is transparent, the result won't be used 264 | if $mid.next.a > 0 { $mid.next.$chan } else { $mid.curr.$chan }, 265 | if $bot.prev.a > 0 { $bot.prev.$chan } else { $mid.curr.$chan }, 266 | if $bot.curr.a > 0 { $bot.curr.$chan } else { $mid.curr.$chan }, 267 | if $bot.next.a > 0 { $bot.next.$chan } else { $mid.curr.$chan }, 268 | ].select_nth_unstable(4).1 269 | } 270 | } 271 | 272 | /// Average of 9 neighboring pixels 273 | macro_rules! blur_channel { 274 | ($top:expr, $mid:expr, $bot:expr, $chan:ident) => {{ 275 | let mut tmp = 0u16; 276 | tmp += u16::from(if $top.prev.a > 0 { $top.prev.$chan } else { $mid.curr.$chan }); 277 | tmp += u16::from(if $top.curr.a > 0 { $top.curr.$chan } else { $mid.curr.$chan }); 278 | tmp += u16::from(if $top.next.a > 0 { $top.next.$chan } else { $mid.curr.$chan }); 279 | tmp += u16::from(if $mid.prev.a > 0 { $mid.prev.$chan } else { $mid.curr.$chan }); 280 | tmp += u16::from($mid.curr.$chan); // if the center pixel is transparent, the result won't be used 281 | tmp += u16::from(if $mid.next.a > 0 { $mid.next.$chan } else { $mid.curr.$chan }); 282 | tmp += u16::from(if $bot.prev.a > 0 { $bot.prev.$chan } else { $mid.curr.$chan }); 283 | tmp += u16::from(if $bot.curr.a > 0 { $bot.curr.$chan } else { $mid.curr.$chan }); 284 | tmp += u16::from(if $bot.next.a > 0 { $bot.next.$chan } else { $mid.curr.$chan }); 285 | (tmp / 9) as u8 286 | }} 287 | } 288 | 289 | #[inline(never)] 290 | pub(crate) fn smart_blur(frame: ImgRef) -> ImgVec { 291 | let mut out = Vec::with_capacity(frame.width() * frame.height()); 292 | loop9_img(frame, |_, _, top, mid, bot| { 293 | out.push_in_cap(if mid.curr.a > 0 { 294 | let median_r = median_channel!(top, mid, bot, r); 295 | let median_g = median_channel!(top, mid, bot, g); 296 | let median_b = median_channel!(top, mid, bot, b); 297 | 298 | let blurred = RGB8::new(median_r, median_g, median_b); 299 | if color_diff(mid.curr.rgb(), blurred) < 16 * 16 * 6 { 300 | blurred 301 | } else { 302 | mid.curr.rgb() 303 | } 304 | } else { 305 | RGB8::new(255, 0, 255) 306 | }); 307 | }); 308 | ImgVec::new(out, frame.width(), frame.height()) 309 | } 310 | 311 | #[inline(never)] 312 | pub(crate) fn less_smart_blur(frame: ImgRef) -> ImgVec { 313 | let mut out = Vec::with_capacity(frame.width() * frame.height()); 314 | loop9_img(frame, |_, _, top, mid, bot| { 315 | out.push_in_cap(if mid.curr.a > 0 { 316 | let median_r = blur_channel!(top, mid, bot, r); 317 | let median_g = blur_channel!(top, mid, bot, g); 318 | let median_b = blur_channel!(top, mid, bot, b); 319 | 320 | let blurred = RGB8::new(median_r, median_g, median_b); 321 | if color_diff(mid.curr.rgb(), blurred) < 16 * 16 * 6 { 322 | blurred 323 | } else { 324 | mid.curr.rgb() 325 | } 326 | } else { 327 | RGB8::new(255, 0, 255) 328 | }); 329 | }); 330 | ImgVec::new(out, frame.width(), frame.height()) 331 | } 332 | 333 | /// The idea is to split colors into two arbitrary groups, and flip-flop weight between them. 334 | /// This might help quantization have less unique colors per frame, and catch up in the next frame. 335 | #[inline(always)] 336 | fn cohort(color: RGB8) -> bool { 337 | (color.r / 2 > color.g) != (color.b > 127) 338 | } 339 | 340 | /// importance = how much it exceeds percetible threshold 341 | #[inline(always)] 342 | fn pixel_importance(diff_with_bg: u32, threshold: u32, min: u8, max: u8) -> u8 { 343 | debug_assert!((u32::from(min) + u32::from(max)) <= 255); 344 | let exceeds = diff_with_bg.saturating_sub(threshold); 345 | min + (exceeds.saturating_mul(u32::from(max)) / (threshold.saturating_mul(48))).min(u32::from(max)) as u8 346 | } 347 | 348 | #[inline(always)] 349 | fn avg8(a: u8, b: u8) -> u8 { 350 | ((u16::from(a) + u16::from(b)) / 2) as u8 351 | } 352 | 353 | #[inline(always)] 354 | fn zip(zip: impl Fn(fn(&(RGB8, RGB8)) -> u8) -> u8) -> RGB8 { 355 | RGB8 { 356 | r: zip(|px| px.0.r), 357 | g: zip(|px| px.0.g), 358 | b: zip(|px| px.0.b), 359 | } 360 | } 361 | 362 | #[inline(always)] 363 | fn get_medians(src: &[(RGB8, RGB8); LOOKAHEAD], len_minus_one: usize) -> RGB8 { 364 | match len_minus_one { 365 | 0 => src[0].0, 366 | 1 => zip(|ch| avg8(ch(&src[0]), ch(&src[1]))), 367 | 2 => zip(|ch| { 368 | let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i])); 369 | tmp.sort_unstable(); 370 | tmp[1] 371 | }), 372 | 3 => zip(|ch| { 373 | let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i])); 374 | tmp.sort_unstable(); 375 | avg8(tmp[1], tmp[2]) 376 | }), 377 | 4 => zip(|ch| { 378 | let mut tmp: [u8; 3] = std::array::from_fn(|i| ch(&src[i])); 379 | tmp.sort_unstable(); 380 | tmp[2] 381 | }), 382 | _ => { 383 | debug_assert!(false); 384 | src[0].0 385 | }, 386 | } 387 | } 388 | 389 | #[inline] 390 | fn color_diff(x: RGB8, y: RGB8) -> u32 { 391 | let x = x.map(i32::from); 392 | let y = y.map(i32::from); 393 | 394 | (x.r - y.r).pow(2) as u32 * 2 + 395 | (x.g - y.g).pow(2) as u32 * 3 + 396 | (x.b - y.b).pow(2) as u32 397 | } 398 | 399 | #[track_caller] 400 | #[cfg(test)] 401 | fn px(f: Denoised) -> (RGBA8, T) { 402 | if let Denoised::Frame { frame, meta, .. } = f { 403 | (frame.pixels().next().unwrap(), meta) 404 | } else { panic!("no frame") } 405 | } 406 | 407 | #[test] 408 | fn one() { 409 | let mut d = Denoiser::new(1, 1, 100).unwrap(); 410 | let w = RGBA8::new(255, 255, 255, 255); 411 | let frame = ImgVec::new(vec![w], 1, 1); 412 | let frame_blurred = smart_blur(frame.as_ref()); 413 | 414 | d.push_frame(frame.as_ref(), frame_blurred.as_ref(), 0).unwrap(); 415 | assert!(matches!(d.pop(), Denoised::NotYet)); 416 | d.flush(); 417 | assert_eq!(px(d.pop()), (w, 0)); 418 | assert!(matches!(d.pop(), Denoised::Done)); 419 | } 420 | 421 | #[test] 422 | fn two() { 423 | let mut d = Denoiser::new(1,1, 100).unwrap(); 424 | let w = RGBA8::new(254,253,252,255); 425 | let b = RGBA8::new(8,7,0,255); 426 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); 427 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap(); 428 | assert!(matches!(d.pop(), Denoised::NotYet)); 429 | d.flush(); 430 | assert_eq!(px(d.pop()), (w, 0)); 431 | assert_eq!(px(d.pop()), (b, 1)); 432 | assert!(matches!(d.pop(), Denoised::Done)); 433 | } 434 | 435 | #[test] 436 | fn three() { 437 | let mut d = Denoiser::new(1,1, 100).unwrap(); 438 | let w = RGBA8::new(254,253,252,255); 439 | let b = RGBA8::new(8,7,0,255); 440 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); 441 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap(); 442 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); 443 | assert!(matches!(d.pop(), Denoised::NotYet)); 444 | d.flush(); 445 | assert_eq!(px(d.pop()), (w, 0)); 446 | assert_eq!(px(d.pop()), (b, 1)); 447 | assert_eq!(px(d.pop()), (b, 2)); 448 | assert!(matches!(d.pop(), Denoised::Done)); 449 | } 450 | 451 | #[test] 452 | fn four() { 453 | let mut d = Denoiser::new(1,1, 100).unwrap(); 454 | let w = RGBA8::new(254,253,252,255); 455 | let b = RGBA8::new(8,7,0,255); 456 | let t = RGBA8::new(0,0,0,0); 457 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); 458 | d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 1).unwrap(); 459 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); 460 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 3).unwrap(); 461 | assert!(matches!(d.pop(), Denoised::NotYet)); 462 | d.flush(); 463 | assert_eq!(px(d.pop()), (w, 0)); 464 | assert_eq!(px(d.pop()), (t, 1)); 465 | assert_eq!(px(d.pop()), (b, 2)); 466 | assert_eq!(px(d.pop()), (w, 3)); 467 | assert!(matches!(d.pop(), Denoised::Done)); 468 | } 469 | 470 | #[test] 471 | fn five() { 472 | let mut d = Denoiser::new(1,1, 100).unwrap(); 473 | let w = RGBA8::new(254,253,252,255); 474 | let b = RGBA8::new(8,7,0,255); 475 | let t = RGBA8::new(0,0,0,0); 476 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); 477 | d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 1).unwrap(); 478 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); 479 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 3).unwrap(); 480 | assert!(matches!(d.pop(), Denoised::NotYet)); 481 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 4).unwrap(); 482 | assert_eq!(px(d.pop()), (w, 0)); 483 | d.flush(); 484 | assert_eq!(px(d.pop()), (t, 1)); 485 | assert_eq!(px(d.pop()), (b, 2)); 486 | assert_eq!(px(d.pop()), (b, 3)); 487 | assert_eq!(px(d.pop()), (w, 4)); 488 | assert!(matches!(d.pop(), Denoised::Done)); 489 | } 490 | 491 | #[test] 492 | fn six() { 493 | let mut d = Denoiser::new(1,1, 100).unwrap(); 494 | let w = RGBA8::new(254,253,252,255); 495 | let b = RGBA8::new(8,7,0,255); 496 | let t = RGBA8::new(0,0,0,0); 497 | let x = RGBA8::new(4,5,6,255); 498 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 0).unwrap(); 499 | assert!(matches!(d.pop(), Denoised::NotYet)); 500 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 1).unwrap(); 501 | assert!(matches!(d.pop(), Denoised::NotYet)); 502 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), 2).unwrap(); 503 | assert!(matches!(d.pop(), Denoised::NotYet)); 504 | d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), 3).unwrap(); 505 | assert!(matches!(d.pop(), Denoised::NotYet)); 506 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), 4).unwrap(); 507 | assert_eq!(px(d.pop()), (w, 0)); 508 | d.push_frame_test(ImgVec::new(vec![x], 1, 1).as_ref(), 5).unwrap(); 509 | d.flush(); 510 | assert_eq!(px(d.pop()), (b, 1)); 511 | assert_eq!(px(d.pop()), (b, 2)); 512 | assert_eq!(px(d.pop()), (t, 3)); 513 | assert_eq!(px(d.pop()), (w, 4)); 514 | assert_eq!(px(d.pop()), (x, 5)); 515 | assert!(matches!(d.pop(), Denoised::Done)); 516 | } 517 | 518 | #[test] 519 | fn many() { 520 | let mut d = Denoiser::new(1,1, 100).unwrap(); 521 | let w = RGBA8::new(255,254,253,255); 522 | let b = RGBA8::new(1,2,3,255); 523 | let t = RGBA8::new(0,0,0,0); 524 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), "w0").unwrap(); 525 | assert!(matches!(d.pop(), Denoised::NotYet)); 526 | d.push_frame_test(ImgVec::new(vec![w], 1, 1).as_ref(), "w1").unwrap(); 527 | assert!(matches!(d.pop(), Denoised::NotYet)); 528 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b2").unwrap(); 529 | assert!(matches!(d.pop(), Denoised::NotYet)); 530 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b3").unwrap(); 531 | assert!(matches!(d.pop(), Denoised::NotYet)); 532 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b4").unwrap(); 533 | assert_eq!(px(d.pop()), (w, "w0")); 534 | d.push_frame_test(ImgVec::new(vec![t], 1, 1).as_ref(), "t5").unwrap(); 535 | assert_eq!(px(d.pop()), (w, "w1")); 536 | d.push_frame_test(ImgVec::new(vec![b], 1, 1).as_ref(), "b6").unwrap(); 537 | assert_eq!(px(d.pop()), (b, "b2")); 538 | d.flush(); 539 | assert_eq!(px(d.pop()), (b, "b3")); 540 | assert_eq!(px(d.pop()), (b, "b4")); 541 | assert_eq!(px(d.pop()), (t, "t5")); 542 | assert_eq!(px(d.pop()), (b, "b6")); 543 | assert!(matches!(d.pop(), Denoised::Done)); 544 | } 545 | -------------------------------------------------------------------------------- /src/encoderust.rs: -------------------------------------------------------------------------------- 1 | use crate::error::CatResult; 2 | use crate::{GIFFrame, Settings, SettingsExt}; 3 | use rgb::RGB8; 4 | use std::cell::Cell; 5 | use std::io::Write; 6 | use std::iter::repeat; 7 | use std::rc::Rc; 8 | 9 | #[cfg(feature = "gifsicle")] 10 | use crate::gifsicle; 11 | 12 | struct CountingWriter { 13 | writer: W, 14 | written: Rc>, 15 | } 16 | 17 | impl Write for CountingWriter { 18 | #[inline(always)] 19 | fn write(&mut self, buf: &[u8]) -> Result { 20 | let len = self.writer.write(buf)?; 21 | self.written.set(self.written.get() + len as u64); 22 | Ok(len) 23 | } 24 | 25 | #[inline(always)] 26 | fn flush(&mut self) -> Result<(), std::io::Error> { 27 | self.writer.flush() 28 | } 29 | } 30 | 31 | pub(crate) struct RustEncoder { 32 | writer: Option, 33 | written: Rc>, 34 | gif_enc: Option>>, 35 | } 36 | 37 | impl RustEncoder { 38 | pub fn new(writer: W, written: Rc>) -> Self { 39 | Self { 40 | written, 41 | writer: Some(writer), 42 | gif_enc: None, 43 | } 44 | } 45 | } 46 | 47 | impl RustEncoder { 48 | #[inline(never)] 49 | #[cfg_attr(debug_assertions, track_caller)] 50 | pub fn compress_frame(f: GIFFrame, settings: &SettingsExt) -> CatResult> { 51 | let GIFFrame {left, top, pal, image, dispose, transparent_index} = f; 52 | 53 | let (buffer, width, height) = image.into_contiguous_buf(); 54 | 55 | let mut pal_rgb = rgb::bytemuck::cast_slice(&pal).to_vec(); 56 | // Palette should be power-of-two sized 57 | if pal.len() != 256 { 58 | let needed_size = 3 * pal.len().max(2).next_power_of_two(); 59 | pal_rgb.extend(repeat([115, 107, 105, 46, 103, 105, 102]).flatten().take(needed_size - pal_rgb.len())); 60 | debug_assert_eq!(needed_size, pal_rgb.len()); 61 | } 62 | let mut frame = gif::Frame { 63 | delay: 1, // TBD 64 | dispose, 65 | transparent: transparent_index, 66 | needs_user_input: false, 67 | top, 68 | left, 69 | width: width as u16, 70 | height: height as u16, 71 | interlaced: false, 72 | palette: Some(pal_rgb), 73 | buffer: buffer.into(), 74 | }; 75 | 76 | #[allow(unused)] 77 | let loss = settings.gifsicle_loss(); 78 | #[cfg(feature = "gifsicle")] 79 | if loss > 0 { 80 | Self::compress_gifsicle(&mut frame, loss)?; 81 | return Ok(frame); 82 | } 83 | 84 | frame.make_lzw_pre_encoded(); 85 | Ok(frame) 86 | } 87 | 88 | #[cfg(feature = "gifsicle")] 89 | #[inline(never)] 90 | fn compress_gifsicle(frame: &mut gif::Frame<'static>, loss: u32) -> CatResult<()> { 91 | use crate::Error; 92 | use gifsicle::{GiflossyImage, GiflossyWriter}; 93 | 94 | let pal = frame.palette.as_ref().ok_or(Error::Gifsicle)?; 95 | let g_pal = pal.chunks_exact(3) 96 | .map(|c| RGB8 { 97 | r: c[0], 98 | g: c[1], 99 | b: c[2], 100 | }) 101 | .collect::>(); 102 | 103 | let gif_img = GiflossyImage::new(&frame.buffer, frame.width, frame.height, frame.transparent, Some(&g_pal)); 104 | 105 | let mut lossy_writer = GiflossyWriter { loss }; 106 | 107 | frame.buffer = lossy_writer.write(&gif_img, None)?.into(); 108 | Ok(()) 109 | } 110 | 111 | pub fn write_frame(&mut self, mut frame: gif::Frame<'static>, delay: u16, screen_width: u16, screen_height: u16, settings: &Settings) -> CatResult<()> { 112 | frame.delay = delay; // the delay wasn't known 113 | 114 | let writer = &mut self.writer; 115 | let enc = match self.gif_enc { 116 | None => { 117 | let w = CountingWriter { 118 | writer: writer.take().ok_or(crate::Error::ThreadSend)?, 119 | written: self.written.clone(), 120 | }; 121 | let mut enc = gif::Encoder::new(w, screen_width, screen_height, &[])?; 122 | enc.write_extension(gif::ExtensionData::Repetitions(settings.repeat))?; 123 | enc.write_raw_extension(gif::Extension::Comment.into(), &[b"gif.ski"])?; 124 | self.gif_enc.get_or_insert(enc) 125 | }, 126 | Some(ref mut enc) => enc, 127 | }; 128 | 129 | enc.write_lzw_pre_encoded_frame(&frame)?; 130 | Ok(()) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::WrongSizeError; 2 | use quick_error::quick_error; 3 | use std::io; 4 | use std::num::TryFromIntError; 5 | 6 | quick_error! { 7 | #[derive(Debug)] 8 | pub enum Error { 9 | /// Internal error 10 | ThreadSend { 11 | display("Internal error; unexpectedly aborted") 12 | } 13 | Aborted { 14 | display("aborted") 15 | } 16 | Gifsicle { 17 | display("gifsicle failure") 18 | } 19 | Gif(err: gif::EncodingError) { 20 | display("GIF encoding error: {}", err) 21 | } 22 | NoFrames { 23 | display("Found no usable frames to encode") 24 | } 25 | Io(err: io::Error) { 26 | from() 27 | from(_oom: std::collections::TryReserveError) -> (io::ErrorKind::OutOfMemory.into()) 28 | display("I/O: {}", err) 29 | } 30 | PNG(msg: String) { 31 | display("{}", msg) 32 | } 33 | WrongSize(msg: String) { 34 | display("{}", msg) 35 | from(e: TryFromIntError) -> (e.to_string()) 36 | from(_e: WrongSizeError) -> ("wrong size".to_string()) 37 | from(e: resize::Error) -> (e.to_string()) 38 | } 39 | Quant(liq: imagequant::liq_error) { 40 | from() 41 | display("pngquant error: {}", liq) 42 | } 43 | Pal(gif: gif_dispose::Error) { 44 | from() 45 | display("gif dispose error: {}", gif) 46 | } 47 | } 48 | } 49 | 50 | #[doc(hidden)] 51 | pub type CatResult = Result; 52 | 53 | /// Alias for `Result` with gifski's [`Error`] 54 | pub type GifResult = Result; 55 | 56 | impl From for Error { 57 | #[cold] 58 | fn from(err: gif::EncodingError) -> Self { 59 | match err { 60 | gif::EncodingError::Io(err) => err.into(), 61 | other => Self::Gif(other), 62 | } 63 | } 64 | } 65 | 66 | impl From> for Error { 67 | #[cold] 68 | fn from(_: ordered_channel::SendError) -> Self { 69 | Self::ThreadSend 70 | } 71 | } 72 | 73 | impl From for Error { 74 | #[cold] 75 | fn from(_: ordered_channel::RecvError) -> Self { 76 | Self::Aborted 77 | } 78 | } 79 | 80 | impl From> for Error { 81 | #[cold] 82 | fn from(_panic: Box) -> Self { 83 | Self::ThreadSend 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/gifsicle.rs: -------------------------------------------------------------------------------- 1 | pub struct GiflossyImage<'data> { 2 | img: &'data [u8], 3 | width: u16, 4 | height: u16, 5 | interlace: bool, 6 | transparent: Option, 7 | pal: Option<&'data [RGB8]>, 8 | } 9 | 10 | use rgb::RGB8; 11 | 12 | use crate::Error; 13 | pub type LzwCode = u16; 14 | 15 | #[derive(Clone, Copy)] 16 | pub struct GiflossyWriter { 17 | pub loss: u32, 18 | } 19 | 20 | struct CodeTable { 21 | pub nodes: Vec, 22 | pub links_used: usize, 23 | pub clear_code: LzwCode, 24 | } 25 | 26 | type NodeId = u16; 27 | 28 | struct Node { 29 | pub code: LzwCode, 30 | pub suffix: u8, 31 | pub children: Vec, 32 | } 33 | 34 | type RgbDiff = rgb::RGB; 35 | 36 | #[inline] 37 | fn color_diff(a: RGB8, b: RGB8, a_transparent: bool, b_transparent: bool, dither: RgbDiff) -> u32 { 38 | if a_transparent != b_transparent { 39 | return (1 << 25) as u32; 40 | } 41 | if a_transparent { 42 | return 0; 43 | } 44 | let dith = 45 | ((i32::from(a.r) - i32::from(b.r) + i32::from(dither.r)) * (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r)) 46 | + (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g)) * (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g)) 47 | + (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b)) * (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b))) as u32; 48 | let undith = 49 | ((i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) / 2) * (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) / 2) 50 | + (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) / 2) * (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) / 2) 51 | + (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) / 2) * (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) / 2)) as u32; 52 | if dith < undith { 53 | dith 54 | } else { 55 | undith 56 | } 57 | } 58 | #[inline] 59 | fn diffused_difference( 60 | a: RGB8, 61 | b: RGB8, 62 | a_transparent: bool, 63 | b_transparent: bool, 64 | dither: RgbDiff, 65 | ) -> RgbDiff { 66 | if a_transparent || b_transparent { 67 | RgbDiff { r: 0, g: 0, b: 0 } 68 | } else { 69 | RgbDiff { 70 | r: (i32::from(a.r) - i32::from(b.r) + i32::from(dither.r) * 3 / 4) as i16, 71 | g: (i32::from(a.g) - i32::from(b.g) + i32::from(dither.g) * 3 / 4) as i16, 72 | b: (i32::from(a.b) - i32::from(b.b) + i32::from(dither.b) * 3 / 4) as i16, 73 | } 74 | } 75 | } 76 | 77 | impl CodeTable { 78 | #[inline] 79 | fn define(&mut self, work_node_id: NodeId, suffix: u8, next_code: LzwCode) { 80 | let id = self.nodes.len() as u16; 81 | self.nodes.push(Node { 82 | code: next_code, 83 | suffix, 84 | children: Vec::new(), 85 | }); 86 | self.nodes[work_node_id as usize].children.push(id); 87 | } 88 | 89 | #[cold] 90 | fn reset(&mut self) { 91 | self.links_used = 0; 92 | self.nodes.clear(); 93 | self.nodes.extend((0..usize::from(self.clear_code)).map(|i| Node { 94 | code: i as u16, 95 | suffix: i as u8, 96 | children: Vec::new(), 97 | })); 98 | } 99 | } 100 | 101 | struct Lookup<'a> { 102 | pub code_table: &'a CodeTable, 103 | pub pal: &'a [RGB8], 104 | pub image: &'a GiflossyImage<'a>, 105 | pub max_diff: u32, 106 | pub best_node: NodeId, 107 | pub best_pos: usize, 108 | pub best_total_diff: u64, 109 | } 110 | 111 | impl Lookup<'_> { 112 | pub fn lossy_node(&mut self, pos: usize, node_id: NodeId, total_diff: u64, dither: RgbDiff) { 113 | let Some(px) = self.image.px_at_pos(pos) else { 114 | return; 115 | }; 116 | self.code_table.nodes[node_id as usize].children.iter().copied().for_each(|node_id| { 117 | self.try_node( 118 | pos, 119 | node_id, 120 | px, 121 | dither, 122 | total_diff, 123 | ); 124 | }); 125 | } 126 | 127 | #[inline] 128 | fn try_node( 129 | &mut self, 130 | pos: usize, 131 | node_id: NodeId, 132 | px: u8, 133 | dither: RgbDiff, 134 | total_diff: u64, 135 | ) { 136 | let node = &self.code_table.nodes[node_id as usize]; 137 | let next_px = node.suffix; 138 | let diff = if px == next_px { 139 | 0 140 | } else { 141 | color_diff( 142 | self.pal[px as usize], 143 | self.pal[next_px as usize], 144 | Some(px) == self.image.transparent, 145 | Some(next_px) == self.image.transparent, 146 | dither, 147 | ) 148 | }; 149 | if diff <= self.max_diff { 150 | let new_dither = diffused_difference( 151 | self.pal[px as usize], 152 | self.pal[next_px as usize], 153 | Some(px) == self.image.transparent, 154 | Some(next_px) == self.image.transparent, 155 | dither, 156 | ); 157 | let new_pos = pos + 1; 158 | let new_diff = total_diff + u64::from(diff); 159 | if new_pos > self.best_pos || new_pos == self.best_pos && new_diff < self.best_total_diff { 160 | self.best_node = node_id; 161 | self.best_pos = new_pos; 162 | self.best_total_diff = new_diff; 163 | } 164 | self.lossy_node(new_pos, node_id, new_diff, new_dither); 165 | } 166 | } 167 | } 168 | 169 | const RUN_EWMA_SHIFT: usize = 4; 170 | const RUN_EWMA_SCALE: usize = 19; 171 | const RUN_INV_THRESH: usize = (1 << RUN_EWMA_SCALE) / 3000; 172 | 173 | impl GiflossyWriter { 174 | pub fn write(&mut self, image: &GiflossyImage, global_pal: Option<&[RGB8]>) -> Result, Error> { 175 | let mut buf = Vec::new(); 176 | buf.try_reserve((image.height as usize * image.width as usize / 4).next_power_of_two())?; 177 | 178 | let mut run = 0; 179 | let mut run_ewma = 0; 180 | let mut next_code = 0; 181 | let pal = image.pal.or(global_pal).unwrap(); 182 | 183 | let min_code_size = (pal.len() as u32).max(3).next_power_of_two().trailing_zeros() as u8; 184 | 185 | buf.push(min_code_size); 186 | let mut bufpos_bits = 8; 187 | 188 | let mut code_table = CodeTable { 189 | clear_code: 1 << u16::from(min_code_size), 190 | links_used: 0, 191 | nodes: Vec::new(), 192 | }; 193 | code_table.reset(); 194 | 195 | let mut cur_code_bits = min_code_size + 1; 196 | let mut output_code = code_table.clear_code as LzwCode; 197 | let mut clear_bufpos_bits = bufpos_bits; 198 | let mut pos = 0; 199 | let mut clear_pos = pos; 200 | loop { 201 | let endpos_bits = bufpos_bits + (cur_code_bits as usize); 202 | loop { 203 | if bufpos_bits & 7 != 0 { 204 | buf[bufpos_bits / 8] |= (output_code << (bufpos_bits & 7)) as u8; 205 | } else { 206 | buf.push((output_code >> (bufpos_bits + (cur_code_bits as usize) - endpos_bits)) as u8); 207 | } 208 | bufpos_bits = bufpos_bits + 8 - (bufpos_bits & 7); 209 | if bufpos_bits >= endpos_bits { 210 | break; 211 | } 212 | } 213 | bufpos_bits = endpos_bits; 214 | 215 | if output_code == code_table.clear_code { 216 | cur_code_bits = min_code_size + 1; 217 | next_code = (code_table.clear_code + 2) as LzwCode; 218 | run_ewma = 1 << RUN_EWMA_SCALE; 219 | code_table.reset(); 220 | clear_bufpos_bits = 0; 221 | clear_pos = clear_bufpos_bits; 222 | } else { 223 | if output_code == (code_table.clear_code + 1) { 224 | break; 225 | } 226 | if next_code > (1 << cur_code_bits) && cur_code_bits < 12 { 227 | cur_code_bits += 1; 228 | } 229 | run = (((run as u32) << RUN_EWMA_SCALE) + (1 << (RUN_EWMA_SHIFT - 1) as u32)) as usize; 230 | if run < run_ewma { 231 | run_ewma = run_ewma - ((run_ewma - run) >> RUN_EWMA_SHIFT); 232 | } else { 233 | run_ewma = run_ewma + ((run - run_ewma) >> RUN_EWMA_SHIFT); 234 | } 235 | } 236 | if let Some(px) = image.px_at_pos(pos) { 237 | let mut l = Lookup { 238 | code_table: &code_table, 239 | pal, 240 | image, 241 | max_diff: self.loss, 242 | best_node: u16::from(px), 243 | best_pos: pos + 1, 244 | best_total_diff: 0, 245 | }; 246 | l.lossy_node(pos + 1, u16::from(px), 0, RgbDiff { r: 0, g: 0, b: 0 }); 247 | run = l.best_pos - pos; 248 | pos = l.best_pos; 249 | let selected_node = &code_table.nodes[l.best_node as usize]; 250 | output_code = selected_node.code; 251 | if let Some(px) = image.px_at_pos(pos) { 252 | if next_code < 0x1000 { 253 | code_table.define(l.best_node, px, next_code); 254 | next_code += 1; 255 | } else { 256 | next_code = 0x1001; 257 | } 258 | if next_code >= 0x0FFF { 259 | let pixels_left = image.img.len() - pos - 1; 260 | let do_clear = pixels_left != 0 261 | && (run_ewma 262 | < (36 << RUN_EWMA_SCALE) / (min_code_size as usize) 263 | || pixels_left > (0x7FFF_FFFF * 2 + 1) / RUN_INV_THRESH 264 | || run_ewma < pixels_left * RUN_INV_THRESH); 265 | if (do_clear || run < 7) && clear_pos == 0 { 266 | clear_pos = pos - run; 267 | clear_bufpos_bits = bufpos_bits; 268 | } else if !do_clear && run > 50 { 269 | clear_bufpos_bits = 8; // buf contains min code 270 | clear_pos = 0; 271 | } 272 | if do_clear { 273 | output_code = code_table.clear_code; 274 | pos = clear_pos; 275 | bufpos_bits = clear_bufpos_bits; 276 | buf.truncate(bufpos_bits.div_ceil(8)); 277 | if buf.len() > bufpos_bits / 8 { 278 | buf[bufpos_bits / 8] &= (1 << (bufpos_bits & 7)) - 1; 279 | } 280 | continue; 281 | } 282 | } 283 | run = (((run as u32) << RUN_EWMA_SCALE) + (1 << (RUN_EWMA_SHIFT - 1) as u32)) as usize; 284 | if run < run_ewma { 285 | run_ewma = run_ewma - ((run_ewma - run) >> RUN_EWMA_SHIFT); 286 | } else { 287 | run_ewma = run_ewma + ((run - run_ewma) >> RUN_EWMA_SHIFT); 288 | } 289 | } 290 | } else { 291 | run = 0; 292 | output_code = code_table.clear_code + 1; 293 | } 294 | } 295 | Ok(buf) 296 | } 297 | } 298 | 299 | impl<'a> GiflossyImage<'a> { 300 | #[must_use] 301 | #[cfg_attr(debug_assertions, track_caller)] 302 | pub fn new( 303 | img: &'a [u8], 304 | width: u16, 305 | height: u16, 306 | transparent: Option, 307 | pal: Option<&'a [RGB8]>, 308 | ) -> Self { 309 | assert_eq!(img.len(), width as usize * height as usize); 310 | GiflossyImage { 311 | img, 312 | width, 313 | height, 314 | interlace: false, 315 | transparent, 316 | pal, 317 | } 318 | } 319 | 320 | #[inline] 321 | fn px_at_pos(&self, pos: usize) -> Option { 322 | if !self.interlace { 323 | self.img.get(pos).copied() 324 | } else { 325 | let y = pos / self.width as usize; 326 | let x = pos - (y * self.width as usize); 327 | self.img.get(self.width as usize * interlaced_line(y, self.height as usize) + x).copied() 328 | } 329 | } 330 | } 331 | 332 | fn interlaced_line(line: usize, height: usize) -> usize { 333 | if line > height / 2 { 334 | line * 2 - (height | 1) 335 | } else if line > height / 4 { 336 | return line * 4 - (height & !1 | 2); 337 | } else if line > height / 8 { 338 | return line * 8 - (height & !3 | 4); 339 | } else { 340 | return line * 8; 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/minipool.rs: -------------------------------------------------------------------------------- 1 | use crate::Error; 2 | use crossbeam_channel::Sender; 3 | use std::num::NonZeroU8; 4 | use std::panic::catch_unwind; 5 | use std::sync::atomic::{AtomicBool, Ordering::Relaxed}; 6 | 7 | #[inline] 8 | pub fn new_channel(num_threads: NonZeroU8, name: &str, producer: P, mut consumer: C) -> Result where 9 | M: Send, 10 | C: Clone + Send + FnMut(M) -> Result<(), Error> + std::panic::UnwindSafe, 11 | P: FnOnce(Sender) -> Result, 12 | { 13 | let (s, r) = crossbeam_channel::bounded(2); 14 | new_scope(num_threads, name, move || producer(s), 15 | move |should_abort| { 16 | for m in r { 17 | if should_abort.load(Relaxed) { 18 | break; 19 | } 20 | consumer(m)?; 21 | } 22 | Ok(()) 23 | }) 24 | } 25 | 26 | pub fn new_scope(num_threads: NonZeroU8, name: &str, waiter: P, consumer: C) -> Result where 27 | C: Clone + Send + FnOnce(&AtomicBool) -> Result<(), Error> + std::panic::UnwindSafe, 28 | P: FnOnce() -> Result, 29 | { 30 | let failed = &AtomicBool::new(false); 31 | std::thread::scope(move |scope| { 32 | let thread = move || { 33 | catch_unwind(move || consumer(failed)) 34 | .map_err(|_| Error::ThreadSend).and_then(|x| x) 35 | .map_err(|e| { 36 | failed.store(true, Relaxed); 37 | e 38 | }) 39 | }; 40 | let handles = std::iter::repeat(thread).enumerate() 41 | .take(num_threads.get().into()) 42 | .map(move |(n, thread)| { 43 | std::thread::Builder::new().name(format!("{name}{n}")).spawn_scoped(scope, thread) 44 | }) 45 | .collect::, _>>() 46 | .map_err(move |_| { 47 | failed.store(true, Relaxed); 48 | Error::ThreadSend 49 | })?; 50 | 51 | let res = waiter().map_err(|e| { 52 | failed.store(true, Relaxed); 53 | e 54 | }); 55 | handles.into_iter().try_for_each(|h| h.join().map_err(|_| Error::ThreadSend)?)?; 56 | res 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/progress.rs: -------------------------------------------------------------------------------- 1 | //! For tracking conversion progress and aborting early 2 | 3 | #[cfg(feature = "pbr")] 4 | #[doc(hidden)] 5 | #[deprecated(note = "The pbr dependency is no longer exposed. Please use a newtype pattern and write your own trait impl for it")] 6 | pub use pbr::ProgressBar; 7 | 8 | use std::os::raw::{c_int, c_void}; 9 | 10 | /// A trait that is used to report progress to some consumer. 11 | pub trait ProgressReporter: Send { 12 | /// Called after each frame has been written. 13 | /// 14 | /// This method may return `false` to abort processing. 15 | fn increase(&mut self) -> bool; 16 | 17 | /// File size so far 18 | fn written_bytes(&mut self, _current_file_size_in_bytes: u64) {} 19 | 20 | /// Not used :( 21 | /// Writing is done when `Writer::write()` call returns 22 | fn done(&mut self, _msg: &str) {} 23 | } 24 | 25 | /// No-op progress reporter 26 | pub struct NoProgress {} 27 | 28 | /// For C 29 | pub struct ProgressCallback { 30 | callback: unsafe extern "C" fn(*mut c_void) -> c_int, 31 | arg: *mut c_void, 32 | } 33 | 34 | unsafe impl Send for ProgressCallback {} 35 | 36 | impl ProgressCallback { 37 | pub fn new(callback: unsafe extern "C" fn(*mut c_void) -> c_int, arg: *mut c_void) -> Self { 38 | Self { callback, arg } 39 | } 40 | } 41 | 42 | impl ProgressReporter for NoProgress { 43 | fn increase(&mut self) -> bool { 44 | true 45 | } 46 | 47 | fn done(&mut self, _msg: &str) {} 48 | } 49 | 50 | impl ProgressReporter for ProgressCallback { 51 | fn increase(&mut self) -> bool { 52 | unsafe { (self.callback)(self.arg) == 1 } 53 | } 54 | 55 | fn done(&mut self, _msg: &str) {} 56 | } 57 | 58 | /// Implement the progress reporter trait for a progress bar, 59 | /// to make it usable for frame processing reporting. 60 | #[cfg(feature = "pbr")] 61 | impl ProgressReporter for ProgressBar where T: std::io::Write + Send { 62 | fn increase(&mut self) -> bool { 63 | self.inc(); 64 | true 65 | } 66 | 67 | fn done(&mut self, msg: &str) { 68 | self.finish_print(msg); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/1.png -------------------------------------------------------------------------------- /tests/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/2.png -------------------------------------------------------------------------------- /tests/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/3.png -------------------------------------------------------------------------------- /tests/a2/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/01.png -------------------------------------------------------------------------------- /tests/a2/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/02.png -------------------------------------------------------------------------------- /tests/a2/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/03.png -------------------------------------------------------------------------------- /tests/a2/04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/04.png -------------------------------------------------------------------------------- /tests/a2/05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/05.png -------------------------------------------------------------------------------- /tests/a2/06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/06.png -------------------------------------------------------------------------------- /tests/a2/07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/07.png -------------------------------------------------------------------------------- /tests/a2/08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/08.png -------------------------------------------------------------------------------- /tests/a2/09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/09.png -------------------------------------------------------------------------------- /tests/a2/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/10.png -------------------------------------------------------------------------------- /tests/a2/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/11.png -------------------------------------------------------------------------------- /tests/a2/12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/12.png -------------------------------------------------------------------------------- /tests/a2/13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/13.png -------------------------------------------------------------------------------- /tests/a2/14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/14.png -------------------------------------------------------------------------------- /tests/a2/15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/15.png -------------------------------------------------------------------------------- /tests/a2/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/16.png -------------------------------------------------------------------------------- /tests/a2/17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/17.png -------------------------------------------------------------------------------- /tests/a2/18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/18.png -------------------------------------------------------------------------------- /tests/a2/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/19.png -------------------------------------------------------------------------------- /tests/a2/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/20.png -------------------------------------------------------------------------------- /tests/a2/21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/21.png -------------------------------------------------------------------------------- /tests/a2/22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/22.png -------------------------------------------------------------------------------- /tests/a2/23.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/23.png -------------------------------------------------------------------------------- /tests/a2/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/24.png -------------------------------------------------------------------------------- /tests/a2/25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/25.png -------------------------------------------------------------------------------- /tests/a2/26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/26.png -------------------------------------------------------------------------------- /tests/a2/27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/27.png -------------------------------------------------------------------------------- /tests/a2/28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/28.png -------------------------------------------------------------------------------- /tests/a2/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/29.png -------------------------------------------------------------------------------- /tests/a2/30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/30.png -------------------------------------------------------------------------------- /tests/a2/31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/31.png -------------------------------------------------------------------------------- /tests/a2/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/32.png -------------------------------------------------------------------------------- /tests/a2/33.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/33.png -------------------------------------------------------------------------------- /tests/a2/34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/34.png -------------------------------------------------------------------------------- /tests/a2/35.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/35.png -------------------------------------------------------------------------------- /tests/a2/36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/36.png -------------------------------------------------------------------------------- /tests/a2/37.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/37.png -------------------------------------------------------------------------------- /tests/a2/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/38.png -------------------------------------------------------------------------------- /tests/a2/39.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/39.png -------------------------------------------------------------------------------- /tests/a2/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/40.png -------------------------------------------------------------------------------- /tests/a2/41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/41.png -------------------------------------------------------------------------------- /tests/a2/42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/42.png -------------------------------------------------------------------------------- /tests/a2/43.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a2/43.png -------------------------------------------------------------------------------- /tests/a3/x0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/x0.png -------------------------------------------------------------------------------- /tests/a3/x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/x1.png -------------------------------------------------------------------------------- /tests/a3/x2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/x2.png -------------------------------------------------------------------------------- /tests/a3/x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/x3.png -------------------------------------------------------------------------------- /tests/a3/x4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/x4.png -------------------------------------------------------------------------------- /tests/a3/x5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/x5.png -------------------------------------------------------------------------------- /tests/a3/y0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/y0.png -------------------------------------------------------------------------------- /tests/a3/y1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/y1.png -------------------------------------------------------------------------------- /tests/a3/y2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/y2.png -------------------------------------------------------------------------------- /tests/a3/y3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/y3.png -------------------------------------------------------------------------------- /tests/a3/y4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/y4.png -------------------------------------------------------------------------------- /tests/a3/y5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/y5.png -------------------------------------------------------------------------------- /tests/a3/z0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/z0.png -------------------------------------------------------------------------------- /tests/a3/z1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/z1.png -------------------------------------------------------------------------------- /tests/a3/z2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/z2.png -------------------------------------------------------------------------------- /tests/a3/z3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/z3.png -------------------------------------------------------------------------------- /tests/a3/z4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/z4.png -------------------------------------------------------------------------------- /tests/a3/z5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImageOptim/gifski/ca0ed80878b18d9160a5896dd917430e82dce4de/tests/a3/z5.png -------------------------------------------------------------------------------- /tests/tests.rs: -------------------------------------------------------------------------------- 1 | use gifski::{new, progress, Settings}; 2 | use imgref::{ImgRef, ImgRefMut, ImgVec}; 3 | use rgb::{ComponentMap, RGBA8}; 4 | use std::path::{Path, PathBuf}; 5 | 6 | #[test] 7 | fn n_frames() { 8 | for num_frames in 1..=11 { 9 | assert_anim_eq(num_frames, frame_filename, None, 0.8); 10 | } 11 | } 12 | 13 | fn assert_anim_eq(num_frames: usize, frame_filename: fn(usize) -> PathBuf, frame_edit: Option)>, max_diff: f64) { 14 | let (c, w) = new(Settings::default()).unwrap(); 15 | 16 | let t = std::thread::spawn(move || { 17 | for n in 0..num_frames { 18 | let pts = if num_frames == 1 { 0.1 } else { n as f64 / 10. }; 19 | let name = frame_filename(n); 20 | if let Some(frame_edit) = frame_edit { 21 | let mut frame = load_frame(&name); 22 | frame_edit(n, frame.as_mut()); 23 | c.add_frame_rgba(n, frame, pts).unwrap(); 24 | } else { 25 | c.add_frame_png_file(n, name, pts).unwrap(); 26 | } 27 | } 28 | }); 29 | 30 | let mut out = Vec::new(); 31 | w.write(&mut out, &mut progress::NoProgress {}).unwrap(); 32 | t.join().unwrap(); 33 | 34 | // std::fs::write(format!("/tmp/anim{num_frames}{max_diff}{}.png", frame_edit.is_some()), &out); 35 | 36 | let mut n = 0; 37 | let mut frames_seen = 0; 38 | for_each_frame(&out, |delay, _, actual| { 39 | frames_seen += 1; 40 | let next_n = delay as usize / 10; 41 | while n < next_n { 42 | let name = frame_filename(n); 43 | let mut expected = load_frame(&name); 44 | if let Some(frame_edit) = frame_edit { 45 | frame_edit(n, expected.as_mut()); 46 | } 47 | assert_images_eq(expected.as_ref(), actual, max_diff, format_args!("n={n}/{num_frames}, {delay}/{next_n}, {}", name.display())); 48 | n += 1; 49 | } 50 | }); 51 | assert!(n == num_frames, "{frames_seen} : {num_frames}"); 52 | } 53 | 54 | fn load_frame(name: &Path) -> ImgVec { 55 | let img = lodepng::decode32_file(name).unwrap(); 56 | ImgVec::new(img.buffer, img.width, img.height) 57 | } 58 | 59 | #[test] 60 | fn all_dupe_frames() { 61 | let (c, w) = new(Settings::default()).unwrap(); 62 | 63 | let t = std::thread::spawn(move || { 64 | c.add_frame_png_file(0, frame_filename(1), 0.1).unwrap(); 65 | c.add_frame_png_file(1, frame_filename(1), 1.2).unwrap(); 66 | c.add_frame_png_file(2, frame_filename(1), 1.3).unwrap(); 67 | }); 68 | 69 | let mut out = Vec::new(); 70 | w.write(&mut out, &mut progress::NoProgress {}).unwrap(); 71 | t.join().unwrap(); 72 | 73 | let mut n = 0; 74 | let mut delays = vec![]; 75 | for_each_frame(&out, |delay, frame, actual| { 76 | let expected = lodepng::decode32_file(frame_filename(1)).unwrap(); 77 | let expected = ImgVec::new(expected.buffer, expected.width, expected.height); 78 | assert_images_eq(expected.as_ref(), actual, 0., format_args!("n={n}, {delay} ")); 79 | delays.push(frame.delay); 80 | n += 1; 81 | }); 82 | assert_eq!(delays, [130]); 83 | } 84 | 85 | #[test] 86 | fn all_but_one_dupe_frames() { 87 | let (c, w) = new(Settings::default()).unwrap(); 88 | 89 | let t = std::thread::spawn(move || { 90 | c.add_frame_png_file(0, frame_filename(0), 0.0).unwrap(); 91 | c.add_frame_png_file(1, frame_filename(1), 1.2).unwrap(); 92 | c.add_frame_png_file(2, frame_filename(1), 1.3).unwrap(); 93 | }); 94 | 95 | let mut out = Vec::new(); 96 | w.write(&mut out, &mut progress::NoProgress {}).unwrap(); 97 | t.join().unwrap(); 98 | 99 | let mut delays = vec![]; 100 | let mut n = 0; 101 | for_each_frame(&out, |delay, frame, actual| { 102 | let name = frame_filename(if n == 0 { 0 } else { 1 }); 103 | let expected = lodepng::decode32_file(&name).unwrap(); 104 | let expected = ImgVec::new(expected.buffer, expected.width, expected.height); 105 | assert_images_eq(expected.as_ref(), actual, 1.7, format_args!("n={n}, {delay} {}", name.display())); 106 | delays.push(frame.delay); 107 | n += 1; 108 | }); 109 | assert_eq!(delays, [120, 20]); 110 | } 111 | 112 | fn frame_filename(n: usize) -> PathBuf { 113 | format!("tests/{}.png", (n % 3) + 1).into() 114 | } 115 | 116 | fn for_each_frame(mut gif_data: &[u8], mut cb: impl FnMut(u32, &gif::Frame, ImgRef)) { 117 | let mut gif_opts = gif::DecodeOptions::new(); 118 | gif_opts.set_color_output(gif::ColorOutput::Indexed); 119 | let mut decoder = gif_opts.read_info(&mut gif_data).unwrap(); 120 | let mut screen = gif_dispose::Screen::new_decoder(&decoder); 121 | 122 | let mut delay = 0; 123 | while let Some(frame) = decoder.read_next_frame().unwrap() { 124 | screen.blit_frame(frame).unwrap(); 125 | delay += u32::from(frame.delay); 126 | cb(delay, frame, screen.pixels_rgba()); 127 | } 128 | } 129 | 130 | #[test] 131 | fn anim3() { 132 | assert_anim_eq(6 * 3, |n| format!("tests/a3/{}{}.png", ["x", "y", "z"][n / 6], n % 6).into(), None, 0.8); 133 | } 134 | 135 | #[test] 136 | fn anim3_transparent1() { 137 | assert_anim_eq(6*3, |n| format!("tests/a3/{}{}.png", ["x","y","z"][n/6], n%6).into(), Some(|_,mut fr| { 138 | fr.pixels_mut().for_each(|px| if px.r == 0 && px.g == 0 { px.a = 0; }); 139 | }), 0.8); 140 | } 141 | 142 | #[test] 143 | fn anim3_transparent2() { 144 | assert_anim_eq(6*3, |n| format!("tests/a3/{}{}.png", ["x","y","z"][n/6], n%6).into(), Some(|_,mut fr| { 145 | fr.pixels_mut().for_each(|px| if px.r != 0 { px.a = 0; }); 146 | }), 0.8); 147 | } 148 | 149 | #[test] 150 | fn anim3_twitch() { 151 | assert_anim_eq(6*3*3, |x| { 152 | let n = (x/3) ^ (x&1); 153 | format!("tests/a3/{}{}.png", ["x","y","z"][n/6], n%6).into() 154 | }, None, 0.8); 155 | } 156 | 157 | #[test] 158 | fn anim3_mix() { 159 | assert_anim_eq(6*3*3, |x| { 160 | let n = (x/3) ^ (x&3); 161 | format!("tests/a3/{}{}.png", ["x","y","z"][(n/6)%3], n%6).into() 162 | }, Some(|n, mut fr| { 163 | fr.pixels_mut().take(12).for_each(|px| { 164 | px.g = px.g.wrapping_add(n as _); 165 | }); 166 | }), 2.); 167 | } 168 | 169 | #[test] 170 | fn anim2_fwd() { 171 | assert_anim_eq(43, |n| format!("tests/a2/{:02}.png", 1 + n).into(), None, 0.8); 172 | } 173 | 174 | #[test] 175 | fn anim2_rev() { 176 | assert_anim_eq(43, |n| format!("tests/a2/{:02}.png", 43 - n).into(), None, 0.8); 177 | } 178 | 179 | #[test] 180 | fn anim2_dupes() { 181 | assert_anim_eq(43 * 2, |n| format!("tests/a2/{:02}.png", 1 + n / 2).into(), None, 0.8); 182 | } 183 | 184 | #[test] 185 | fn anim2_flips() { 186 | assert_anim_eq(43 * 2, |n| format!("tests/a2/{:02}.png", if n & 1 == 0 { 10 } else { 1 + n / 2 }).into(), None, 0.8); 187 | } 188 | 189 | #[test] 190 | fn anim2_transparent() { 191 | assert_anim_eq(43, |n| format!("tests/a2/{:02}.png", 1+n).into(), Some(|_, mut fr| { 192 | fr.pixels_mut().for_each(|px| if px.r > 128 { px.a = 0; }); 193 | }), 0.8); 194 | } 195 | 196 | #[test] 197 | fn anim2_transparent2() { 198 | assert_anim_eq(43, |n| format!("tests/a2/{:02}.png", 43-n).into(), Some(|_, mut fr| { 199 | fr.pixels_mut().for_each(|px| if px.g > 200 { px.a = 0; }); 200 | }), 0.8); 201 | } 202 | 203 | #[test] 204 | fn anim2_transparent_half() { 205 | assert_anim_eq(43, |n| format!("tests/a2/{:02}.png", 43-n).into(), Some(|_, mut fr| { 206 | let n = fr.width()*(fr.height()/2); 207 | fr.pixels_mut().skip(n).for_each(|px| if px.g > 200 { px.a = 0; }); 208 | }), 0.8); 209 | } 210 | 211 | #[track_caller] 212 | fn assert_images_eq(a: ImgRef, b: ImgRef, max_diff: f64, msg: impl std::fmt::Display) { 213 | let diff = a.pixels().zip(b.pixels()).map(|(a,b)| { 214 | if a.a != b.a { 215 | return 300000; 216 | } 217 | if a.a == 0 { 218 | return 0; 219 | } 220 | let a = a.map(i32::from); 221 | let b = b.map(i32::from); 222 | let d = a - b; 223 | (d.r * d.r * 2 + 224 | d.g * d.g * 3 + 225 | d.b * d.b) as u64 226 | }).sum::() as f64 / (a.width() * a.height() * 3) as f64; 227 | if diff > max_diff { 228 | dump("expected", a); 229 | dump("actual", b); 230 | } 231 | assert!(diff <= max_diff, "{diff} diff > {max_diff} {msg}"); 232 | } 233 | 234 | fn dump(filename: &str, px: ImgRef) { 235 | let (buf, w, h) = px.to_contiguous_buf(); 236 | lodepng::encode32_file(format!("/tmp/gifski-test-{filename}.png"), &buf, w, h).unwrap(); 237 | } 238 | --------------------------------------------------------------------------------