Last active
January 12, 2026 21:37
-
-
Save Winterhuman/21d7b148db40ff041f397b07a7aafb83 to your computer and use it in GitHub Desktop.
A script for finding the smallest lossless encoding of a PNG or GIF input image (that I know of).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S unshare --pid --mount-proc --kill-child --map-root-user /bin/sh | |
| # shellcheck shell=sh | |
| # Licensed under the Zero-Clause BSD terms: https://opensource.org/license/0bsd | |
| ## Requires: find, awk, oxipng, and gifsicle | |
| ## Optional: pngquant, cwebp & gif2webp, gif2apng, and (perl-image-)exiftool | |
| ## -C: Fail if redirects try to overwrite an existing file. | |
| ## -e: Fail if any command fails (with exceptions). | |
| ## -u: Fail if an unset variable tries to be expanded. | |
| ## -f: No glob expansion. | |
| set -Ceuf | |
| perr() { printf "\a\033[1;31m‽\033[0;39m %s\033[0;39m\n" "${1-"$(cat)"}" >&2; } | |
| clear_prog() { printf "\033]9;4;0\007" >&2; } | |
| exit_clear() { clear_prog; exit 1; } | |
| osc777() { printf "\a\033]777;notify;Sisyphus;%b\033\\" "$1" >&2; } | |
| nexit() { osc777 "${1-"$(cat)"}"; exit_clear; } | |
| pquit() { arg="${1-"$(cat)"}"; perr "$arg"; nexit "$arg"; } | |
| pstat() { printf "\033[1;34m%20b\033[0;39m%s\033[0;39m\n" "$1" "${2-"$(cat)"}"; } | |
| ## `kill` handles background jobs, but `exit` is required for normal processes. | |
| ### The second trap handles unexpected signals, where a notification IS desired. | |
| #### Trying to trap SIGTERM leads to a "Segmentation fault" error | |
| trap 'kill "$$"; exit_clear' INT | |
| trap 'kill "$$"; nexit "SIGQUIT or SIGABRT received! Was operating on: $1"' QUIT ABRT | |
| help() { | |
| cat <<"HELP"; exit 0 | |
| Usage: sisyphus [OPTION]... SRC [DEST] | |
| Losslessly optimise PNGs & GIFs by all known means. | |
| If DEST is omitted, DEST is set to '{SRC wo/ext}.new.EXT'. Possible output | |
| formats include: PNG, GIF, WebP, and APNG. | |
| Options: | |
| -a, --all-oxi <bool> Optionally accepts a boolean value. Controls whether to | |
| always or never try all OxiPNG variations. When this | |
| option is omitted, a heuristic determines whether any | |
| OxiPNG variations are attempted. | |
| -b, --brute-lines <int> Accepts an integer between 1 and the SRC image's height. | |
| Sets the maximum '--brute-lines' value for the OxiPNG | |
| variations. The default is '16'. | |
| -d, --dry-run Perform all operations except for writing the output. | |
| -f, --force Allow existing destination files to be overwritten. | |
| -m, --max-procs <int> Accepts an integer greater than 0. Limits the number of | |
| simultaneous processes for 'xargs'. The default is '8'. | |
| -n, --no-webp Do not attempt to use CWebP nor GIF2WebP. | |
| -N, --no-apng Do not attempt to use GIF2APNG. | |
| -q, --quiet Do not print to STDOUT or send notifications. This option | |
| implies '--results 0', but this can be overridden after. | |
| -r, --results <arg> Accepts a boolean value or an integer greater than 0. | |
| Controls how the results list is printed, if at all. | |
| If <arg> is an integer, only the top <arg> trials will | |
| be printed. The results are printed to STDERR. | |
| -s, --size <int> Accepts an integer in bytes. Sets the minimum size target. | |
| If the best encoding is greater or equal to the minimum | |
| size, then DEST is not created. | |
| -S, --stdout Write the best output to STDOUT. This option semi-implies | |
| '--quiet' except it does NOT also imply '--results 0'. | |
| -h, --help Display this help message, and then exit. | |
| Booleans: | |
| -a, --all-oxi Disable: '0', 'no', or 'false'. | |
| Enable: '1', 'yes', or 'true'. | |
| -r, --results Hide: 'no', 'false', 'hide', or 'none'. | |
| Show: 'yes', 'true', 'show', or 'all'. | |
| Warning: | |
| HDR SRC images will NOT be losslessly optimised! | |
| HELP | |
| } | |
| # Setup a private TMPFS for this script to use, which is what 'unshare' is for. | |
| ## 'nr_inodes' must be >= to 'max number of files + 1'. This is adjusted later | |
| work="/tmp" | |
| mount --types tmpfs --options nosuid,nodev,noexec,size=50%,nr_inodes=16 \ | |
| sisyphus "$work"/ || pquit "Failed to overmount '$work/'!" | |
| # Argument parsing | |
| ALL_OXI="" MAX_BLIN="16" DRY="" FORCE="" MAX_PROCS="8" | |
| NO_WEBP="" NO_APNG="" STDOUT="" RESULTS="0" SIZE="" | |
| INTERNAL="" ARG_COUNT="0" SKIP="" | |
| while [ "$ARG_COUNT" -lt "$#" ]; do | |
| if [ -z "$SKIP" ]; then | |
| case "$1" in | |
| -a|--all-oxi) | |
| case "${2:-}" in | |
| 0|no|false) ALL_OXI="0"; shift ;; | |
| 1|yes|true) ALL_OXI="1"; shift ;; | |
| *) ALL_OXI="1" ;; | |
| esac | |
| ;; | |
| -b|--brute-lines) | |
| case "${2:-}" in | |
| [1-9]*) MAX_BLIN="$2"; shift ;; | |
| *) pquit "No positive integer greater than zero was given for '-b|--brute-lines'!" ;; | |
| esac | |
| ;; | |
| -d|--dry-run) DRY="1" ;; | |
| -f|--force) FORCE="1" ;; | |
| -m|--max-procs) | |
| case "${2:-}" in | |
| [1-9]*) MAX_PROCS="$2"; shift ;; | |
| *) pquit "No positive integer greater than zero was given for '-m|--max-procs'!" ;; | |
| esac | |
| ;; | |
| -n|--no-webp) NO_WEBP="1" ;; | |
| -N|--no-apng) NO_APNG="1" ;; | |
| -q|--quiet|-S|--stdout) | |
| ## Only allow writing to STDOUT on FD 8. | |
| ### Don't do `exec 8>&1` twice or else FD 8 will | |
| ### point to `/dev/null` too | |
| if [ "$(readlink /proc/self/fd/1)" != "/dev/null" ]; then | |
| exec 8>&1 | |
| exec 1>/dev/null | |
| fi | |
| ## '--results 0' should only be set when | |
| ## '--quiet' is used; '--stdout' doesn't do this | |
| case "$1" in | |
| -S|--stdout) STDOUT="1" ;; | |
| -q|--quiet) RESULTS="0" ;; | |
| esac | |
| ## Functions have to be redefined to be updated. | |
| ### This `perr()` omits `\a` | |
| perr() { printf "\033[1;31m‽\033[0;39m %s\033[0;39m\n" "${1-"$(cat)"}" >&2; } | |
| osc777() { :; } | |
| pquit() { perr "$1"; exit_clear; } | |
| ;; | |
| -r|--results) | |
| case "${2:-}" in | |
| no|false|hide|none) shift ;; | |
| yes|true|show|all) RESULTS="ALL"; shift ;; | |
| [0-9]*) RESULTS="$2"; shift ;; | |
| *) pquit "An unknown value was given for '-r|--results'!" ;; | |
| esac | |
| ;; | |
| -s|--size) | |
| case "${2:-}" in | |
| [0-9]*) SIZE="$2"; shift ;; | |
| *) pquit "No positive integer was given for '-s|--size'!" ;; | |
| esac | |
| ;; | |
| -h|--help) help ;; | |
| --_internal) | |
| ## DO NOT USE MANUALLY. For self-execution | |
| INTERNAL="1" | |
| ;; | |
| --) SKIP="1" ;; | |
| -*) pquit "'$1' is not a known option!" ;; | |
| *) set -- "$@" "$1"; ARG_COUNT="$(( ARG_COUNT + 1 ))" ;; | |
| esac | |
| else set -- "$@" "$1"; ARG_COUNT="$(( ARG_COUNT + 1 ))" | |
| fi | |
| shift | |
| done | |
| # Validate arguments | |
| ## `[ cond ] && cmd` will carry over non-zero exit codes; always use `||` | |
| [ "$#" -le 2 ] || pquit "Too many arguments given!" | |
| ## Don't try WebP if `cwebp` & `gif2webp` aren't available | |
| command -v cwebp >/dev/null || NO_WEBP="1" | |
| command -v gif2webp >/dev/null || NO_WEBP="1" | |
| ## Don't try APNG if `gif2apng` isn't available | |
| command -v gif2apng >/dev/null || NO_APNG="1" | |
| ## Don't try `pngquant` if it's not available | |
| NO_QUANT="" | |
| command -v pngquant >/dev/null || NO_QUANT="1" | |
| ## Don't try anything if `find` or `awk` are unavailable | |
| command -v find >/dev/null || pquit "The 'find' command is required!" | |
| command -v awk >/dev/null || pquit "The 'awk' command is required!" | |
| ## SRC | |
| src_real="${1:?$(pquit "No source file was specified!")}" | |
| [ -s "$src_real" ] || pquit "'$src_real' is not a non-empty file!" | |
| ### Assign the SRC a file descriptor | |
| exec 3<"$src_real" | |
| src="/proc/self/fd/3" | |
| src_real="$(realpath -- "$src_real")" | |
| ### Size detection | |
| find_size() { | |
| # Handle non-existent files by giving them arbitrarily huge sizes | |
| if [ -f "$1" ]; then | |
| wc --bytes <"$1" | |
| else | |
| printf "4294967296" | |
| fi | |
| } | |
| src_size="$(find_size "$src")" | |
| ### Minimum size target validation | |
| check_number() { | |
| [ "$1" != "" ] || return 0 | |
| num="$1" oldnum="$num" option="$2" | |
| ## Strip leading zeros and avoid integer overflow | |
| num="$(printf "%s" "$num" | sed "s/^0*//")" | |
| ## If `sed` trimmed '0' to nothing, undo that | |
| num="${num:-"0"}" | |
| if ! printf "%d" "$num" >/dev/null 2>&1; then | |
| perr "The value given for '$option' ($oldnum) is not an integer, or is far beyond the integer limit!" | |
| return 1 | |
| fi | |
| if [ "$num" -ne "$(printf "%.12s" "$num")" ]; then | |
| perr "The value given for '$option' ($oldnum) is over the integer limit!" \ | |
| return 1 | |
| fi | |
| printf "%d" "$num" | |
| } | |
| SIZE="$(check_number "$SIZE" "-s|--size")" | |
| ### Max processes validation | |
| MAX_PROCS="$(check_number "$MAX_PROCS" "-m|--max-procs")" | |
| ### Max `--brute-lines` validation | |
| MAX_BLIN="$(check_number "$MAX_BLIN" "-b|--brute-lines")" | |
| ## Results validation | |
| [ "$RESULTS" = "ALL" ] || RESULTS="$(check_number "$RESULTS" "-r|--results")" | |
| ### Mimetype detection | |
| mime="$(file --dereference --brief --mime-type -- "$src")" | |
| mode="PNG" | |
| safe="all" | |
| case "$mime" in | |
| "image/png") : ;; | |
| "image/gif") mode="GIF" safe="safe" ;; | |
| *) pquit "The mimetype of '$src_real' is neither PNG nor GIF!" ;; | |
| esac | |
| #### When '$INTERNAL' is set, the input is APNG and must be safely stripped | |
| [ -z "$INTERNAL" ] || safe="safe" | |
| src_height="$(file --dereference --brief -- "$src" | | |
| sed -n "s/.*x \([[:digit:]]\+\).*/\1/p")" | |
| src_height="$(check_number "$src_height" "\$src_height")" | |
| ## DEST | |
| dest_exists() { | |
| if [ -n "$FORCE" ] || [ -n "$STDOUT" ]; then return 0; fi | |
| ## Allow globbing for just this for-loop | |
| set +f | |
| for similar in "$1"*; do | |
| [ -e "$similar" ] || continue | |
| if [ "${similar%.*}" = "$1" ]; then | |
| perr "'$similar' shares a filename with '$1'!" | |
| return 1 | |
| fi | |
| done | |
| set -f | |
| } | |
| dest_path="${2:-}" | |
| if [ -n "$STDOUT" ]; then | |
| [ -z "$dest_path" ] || | |
| pquit "A destination, '$dest_path', and '--stdout' were both specified!" | |
| else | |
| dest_path="${dest_path%.*}" | |
| dest_path="${dest_path:-${src_real%.*}.new}" | |
| dest_path="$(realpath -- "$dest_path")" | |
| dest_exists "$dest_path" || | |
| nexit "'$similar' shares a filename with '$dest_path'!" | |
| fi | |
| # Print known information | |
| printf "%s \033[2m(%s)" "$src_real" "$mime" | pstat "Input ← " | |
| printf "%s\033[2m.???" "$dest_path" | pstat "Output template ⇨ " | |
| [ -z "$SIZE" ] || pstat "Size target ↘ " "$SIZE bytes" | |
| # Optimisation | |
| create_png_bases() { | |
| cwebp_base="$work/cwebp.webp" | |
| if [ "$NO_WEBP" != "1" ]; then | |
| ## `-z 9` implies `-q 100 -m 6 -lossless`; | |
| ## `-z 9 -q 100` disables lossless mode | |
| cwebp -quiet -mt -z 9 -alpha_filter best -o "$cwebp_base" \ | |
| -- "$src" || | |
| perr "Passing '$src_real' through 'cwebp' failed!" | |
| fi & | |
| ## Values above the SRC height aren't useful for `--brute-lines`. | |
| ## For the baseline, limit it to a sensible value: 8. | |
| baseline_max_blin="$(( | |
| src_height > 8 | |
| ? 8 | |
| : src_height | |
| ))" | |
| ## If `--brute-lines` was set below 8, respect that too | |
| baseline_max_blin="$(( | |
| baseline_max_blin > MAX_BLIN | |
| ? MAX_BLIN | |
| : baseline_max_blin | |
| ))" | |
| oxipng_base="$work/oxipng + zc12 + f9 + level=5 + lines=$baseline_max_blin.png" | |
| { | |
| oxipng --opt max --filters 9 --brute-level=5 \ | |
| --brute-lines="$baseline_max_blin" --strip "$safe" \ | |
| --alpha --out "$oxipng_base" -- "$src" \ | |
| >/dev/null 2>&1 || | |
| perr "Creating '$oxipng_base' failed!" | |
| } & | |
| if [ "$safe" != "safe" ] && [ -z "$NO_QUANT" ]; then | |
| quant_src="$work/quant.png" | |
| ## '$quant_src' won't be created if it can't be quantised | |
| ## losslessly (`--quality 100-100`). | |
| ### In that case, it'll exit with code 99 | |
| pngquant --quality 100-100 --speed 1 --strip \ | |
| --output "$quant_src" -- "$src" ||: | |
| ## If '$src' was already quantised beforehand, '$quant_src' will | |
| ## just be a duplicate that should be removed. | |
| ### `unset` works after `||/&&`, unlike `quant_src=""` | |
| [ -f "$quant_src" ] || unset quant_src | |
| if cmp -- "$src" "${quant_src:-}" >/dev/null 2>&1; then | |
| rm -- "$quant_src" || | |
| perr "Failed to remove '$quant_src'!" | |
| unset quant_src | |
| fi | |
| fi | |
| wait | |
| oxipng_base_size="$(find_size "$oxipng_base")" | |
| cwebp_base_size="$(find_size "$cwebp_base")" | |
| smallest_heuristic="$(( | |
| oxipng_base_size < cwebp_base_size | |
| ? oxipng_base_size | |
| : cwebp_base_size | |
| ))" | |
| ## '$smallest_known' gets read by `try_oxi_vars` | |
| smallest_known="$(( | |
| smallest_heuristic < src_size | |
| ? smallest_heuristic | |
| : src_size | |
| ))" | |
| ## If '$SIZE' was given, factor it into the smallest-known size value | |
| [ -z "$SIZE" ] || | |
| smallest_known="$(( | |
| smallest_heuristic < SIZE | |
| ? smallest_heuristic | |
| : SIZE | |
| ))" | |
| } | |
| gen_list() { | |
| # Create the command list `xargs` will parse | |
| # For GIF mode | |
| if [ "$mode" = "GIF" ]; then | |
| gif2webp_dest="$work/gif2webp.webp" | |
| gif2apng_prefix="$work/gif2apng + z" | |
| gifsicle_prefix="$work/gifsicle + o" | |
| nproc="$(nproc)" | |
| # Skip `gif2webp` and or `gif2apng` if requested. | |
| [ -n "$NO_WEBP" ] || cat <<-GIF2WEBP | |
| $src $gif2webp_dest gif2webp -quiet -mt -min_size -m 6 -q 100 -metadata none | |
| GIF2WEBP | |
| [ -n "$NO_APNG" ] || cat <<-GIF2APNG | |
| $src ${gif2apng_prefix}0.apng gif2apng -i20 -z0 | |
| $src ${gif2apng_prefix}1.apng gif2apng -i20 -z1 | |
| $src ${gif2apng_prefix}2.apng gif2apng -i20 -z2 | |
| $src ${gif2apng_prefix}0 + kp.apng gif2apng -i20 -z0 -kp | |
| $src ${gif2apng_prefix}1 + kp.apng gif2apng -i20 -z1 -kp | |
| $src ${gif2apng_prefix}2 + kp.apng gif2apng -i20 -z2 -kp | |
| GIF2APNG | |
| cat <<-GIFSICLE | |
| $src ${gifsicle_prefix}3.gif gifsicle --optimize=3 --optimize=keep-empty --threads="$nproc" | |
| $src ${gifsicle_prefix}2.gif gifsicle --optimize=2 --optimize=keep-empty --threads="$nproc" | |
| $src ${gifsicle_prefix}1.gif gifsicle --optimize=1 --optimize=keep-empty --threads="$nproc" | |
| GIFSICLE | |
| return 0 | |
| fi | |
| # For PNG mode | |
| pre="$work/oxipng" | |
| osrc="$src" | |
| ## Duplicate the list for '$quant_src' (if set). This is `break`ed later | |
| while :; do | |
| ## `--brute-{level,lines}` only applies to the brute filter (`-f 9`). | |
| zc="1" | |
| while [ "$zc" -le 12 ]; do | |
| pzc="$(printf "%-2s" "$zc")" | |
| cat <<-ZC | |
| $osrc $pre + zc$pzc + f0-8.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 | |
| $osrc $pre + zc$pzc + f0-8 + nb.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb | |
| $osrc $pre + zc$pzc + f0-8 + nc.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nc | |
| $osrc $pre + zc$pzc + f0-8 + nb + nc.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --nc | |
| $osrc $pre + zc$pzc + f0-8 + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --ng | |
| $osrc $pre + zc$pzc + f0-8 + nb + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --ng | |
| $osrc $pre + zc$pzc + f0-8 + nc + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nc --ng | |
| $osrc $pre + zc$pzc + f0-8 + nb + nc + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --nc --ng | |
| $osrc $pre + zc$pzc + f0-8 + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --np | |
| $osrc $pre + zc$pzc + f0-8 + nb + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --np | |
| $osrc $pre + zc$pzc + f0-8 + nc + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nc --np | |
| $osrc $pre + zc$pzc + f0-8 + nb + nc + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --nc --np | |
| $osrc $pre + zc$pzc + f0-8 + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --ng --np | |
| $osrc $pre + zc$pzc + f0-8 + nb + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --ng --np | |
| $osrc $pre + zc$pzc + f0-8 + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nc --ng --np | |
| $osrc $pre + zc$pzc + f0-8 + nb + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=0-8 --nb --nc --ng --np | |
| ZC | |
| zc="$(( zc + 1 ))" | |
| done | |
| cat <<-ZOP | |
| $osrc $pre + zop + f0-8.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 | |
| $osrc $pre + zop + f0-8 + nb.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb | |
| $osrc $pre + zop + f0-8 + nc.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nc | |
| $osrc $pre + zop + f0-8 + nb + nc.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --nc | |
| $osrc $pre + zop + f0-8 + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --ng | |
| $osrc $pre + zop + f0-8 + nb + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --ng | |
| $osrc $pre + zop + f0-8 + nc + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nc --ng | |
| $osrc $pre + zop + f0-8 + nb + nc + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --nc --ng | |
| $osrc $pre + zop + f0-8 + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --np | |
| $osrc $pre + zop + f0-8 + nb + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --np | |
| $osrc $pre + zop + f0-8 + nc + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nc --np | |
| $osrc $pre + zop + f0-8 + nb + nc + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --nc --np | |
| $osrc $pre + zop + f0-8 + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --ng --np | |
| $osrc $pre + zop + f0-8 + nb + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --ng --np | |
| $osrc $pre + zop + f0-8 + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nc --ng --np | |
| $osrc $pre + zop + f0-8 + nb + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=0-8 --nb --nc --ng --np | |
| ZOP | |
| ## `--brute-lines` only makes sense up to the height of the SRC image | |
| blin_max="$(( | |
| src_height > MAX_BLIN | |
| ? MAX_BLIN | |
| : src_height | |
| ))" | |
| ## If '$src_height' is 1, ensure at least 1 line is tried. | |
| blin="2" | |
| if [ "$blin_max" -lt "$blin" ]; then | |
| blin="1" | |
| blin_max="$blin" | |
| fi | |
| while [ "$blin" -le "$blin_max" ]; do | |
| blev="1" | |
| while [ "$blev" -le 12 ]; do | |
| ## Ensure numbers like '1' and '12' are the same length | |
| pblin="$(printf "%-2s" "$blin")" | |
| pblev="$(printf "%-2s" "$blev")" | |
| zc="1" | |
| while [ "$zc" -le 12 ]; do | |
| pzc="$(printf "%-2s" "$zc")" | |
| ## `${pblin% *}` prevents "... lines=N .png" | |
| cat <<-ZCF9 | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=${pblin% *}.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=${pblin% *} | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nc.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + nc.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --ng | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --ng | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nc + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc --ng | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + nc + ng.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc --ng | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nc + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + nc + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --ng --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --ng --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc --ng --np | |
| $osrc $pre + zc$pzc + f9 + level=$pblev + lines=$pblin + nb + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zc=$pzc --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc --ng --np | |
| ZCF9 | |
| zc="$(( zc + 1 ))" | |
| done | |
| ## Don't duplicate the `--zopfli` lines per `--zc` level | |
| cat <<-ZOPF9 | |
| $osrc $pre + zop + f9 + level=$pblev + lines=${pblin% *}.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=${pblin% *} | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nc.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + nc.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --ng | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --ng | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nc + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc --ng | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + nc + ng.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc --ng | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nc + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + nc + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --ng --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --ng --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nc --ng --np | |
| $osrc $pre + zop + f9 + level=$pblev + lines=$pblin + nb + nc + ng + np.png oxipng --opt max --alpha --strip $safe --zopfli --zi=1024 --ziwi=16 --filters=9 --brute-level=$pblev --brute-lines=$pblin --nb --nc --ng --np | |
| ZOPF9 | |
| blev="$(( blev + 1 ))" | |
| done | |
| blin="$(( blin + 1 ))" | |
| done | |
| ## If '$quant_src' is set, generate the list for each of '$src' and | |
| ## '$quant_src' sequentially | |
| if [ "$osrc" = "${quant_src:-}" ] || [ -z "${quant_src:-}" ]; then | |
| break; fi | |
| osrc="$quant_src" pre="$work/quant > oxipng" | |
| done ## This `done` is for the `while :;` statement | |
| } | |
| gen_script() { | |
| cat <<-"SCRIPT" | |
| ## `noexec` on `$work` prevents having `#!/bin/sh` here instead | |
| set -Ceuf | |
| ## Redirect this STDERR to FD 9. This is reverted outside `xargs` | |
| exec 2>&9 | |
| perr() { | |
| arg="${1-"$(cat)"}" | |
| printf "\a\033[1;31m‽\033[0;39m %s\033[0;39m\n" "$arg" >&2 | |
| printf "\033]777;notify;Sisyphus;%b\033\\" "$arg" | |
| } | |
| ## `exit 255` prevents `xargs` from processing any more line batches (the | |
| ## batches from `--max-procs`) | |
| pquit() { perr "${1-"$(cat)"}"; exit 255; } | |
| smallest_known="$1" total_lines="$2" oxipng_base="$3" | |
| ## Parse the line | |
| IFS=" " read -r index input output cmd <<-LINE ||: | |
| $4 | |
| LINE | |
| ## Check if the output (e.g. '$oxipng_base') already exists | |
| [ ! -f "${output:-}" ] || exit 0 | |
| ## Update the progress bar now. Otherwise, long-running '$cmd's will cause the | |
| ## bar to jump backwards on their completion. | |
| ### Without `>&2`, and when `--stdout` is used, the updates get redirected too | |
| printf "\033]9;4;1;%d\007" "$(( ( index * 100 ) / total_lines ))" >&2 | |
| ## Handle each command differently where needed | |
| case "$cmd" in | |
| quant*|oxipng*) | |
| env --split-string "$cmd" --out "$output" -- "$input" \ | |
| 2>&1 || pquit "'$cmd --out $output -- $input' failed!" | |
| ;; | |
| gif2webp*|gifsicle*) | |
| env --split-string "$cmd" -o "$output" -- "$input" \ | |
| 2>&1 || pquit "'$cmd -o $output -- $input' failed!" | |
| ;; | |
| gif2apng*) | |
| ## `gif2apng` only accepts relative paths for its arguments: | |
| ## https://sourceforge.net/p/gif2apng/discussion/1022150/thread/8ec5e7e288 | |
| input_rel="$(realpath --relative-to="$PWD" -- "$input")" | |
| output_rel="$(realpath --relative-to="$PWD" -- "$output")" | |
| env --split-string "$cmd" -- "$input_rel" "$output_rel" \ | |
| 2>&1 || pquit "'$cmd -- $input_rel $output_rel' failed!" | |
| exiftool -overwrite_original_in_place -all= -- "$output" 2>&1 ||: | |
| ;; | |
| *) pquit "Unknown command: $cmd" ;; | |
| esac >/dev/null | |
| ## If the output doesn't exist, don't continue | |
| [ -f "$output" ] || exit 0 | |
| ## Also, don't continue if '$oxipng_base' is unset | |
| [ -n "${oxipng_base:-}" ] || exit 0 | |
| discard() { | |
| trial="$(realpath -- "$1")" | |
| baseline="$(realpath -- "$2")" | |
| [ -s "$trial" ] || return 0 | |
| [ -s "$baseline" ] || | |
| perr "'$baseline' does not exist, or has no content, but should!" | |
| if cmp "$1" "$2" >/dev/null; then | |
| ## This handles otherwise truncated trials which are | |
| ## ordered before '$baseline' in the final list | |
| ln --force --symbolic "$baseline" "$trial" \ | |
| 2>/dev/null || | |
| perr "Couldn't replace '$trial' with a symlink to '$baseline'!" | |
| else | |
| trial_size="$(wc --bytes <"$trial")" | |
| [ "$smallest_known" -gt "$trial_size" ] || | |
| fallocate --punch-hole --length="$trial_size" \ | |
| -- "$trial" | |
| fi | |
| } | |
| ## If '$quant_src' was the input, the output should be compared to that instead | |
| [ "$output" = "${output#quant > }" ] || oxipng_base="$input" | |
| discard "$output" "$oxipng_base" | |
| SCRIPT | |
| } | |
| optimise() { | |
| if [ "$mode" = "PNG" ]; then | |
| # Create the PNG-mode baselines, and decide on whether | |
| # further variants need to be created | |
| create_png_bases | |
| ## If requested (`-a 0`), skip the OxiPNG variants | |
| [ "$ALL_OXI" != "0" ] || return 0 | |
| ## Skip the OxiPNG variants if: | |
| ## 1. It's not been explicitly requested to try them. | |
| ## 2. And, if the baseline OxiPNG size is greater than | |
| ## 256 bytes. | |
| ## 3. And, if the input image is greater in size than | |
| ## the smallest known encoding. | |
| if [ "$ALL_OXI" != "1" ] && | |
| [ "$oxipng_base_size" -gt 256 ] && | |
| [ "$src_size" -gt "$smallest_heuristic" ]; then | |
| return 0 | |
| fi | |
| # Calculate '$total_lines' for use in progress tracking. | |
| ## Levels index from 2, so subtract 1 | |
| levels="$(( | |
| (src_height > MAX_BLIN ? MAX_BLIN : src_height) | |
| - 1 | |
| ))" | |
| ## Also, ensure '$levels' isn't less than 1 | |
| levels="$(( levels > 0 ? levels : 1 ))" | |
| zcs="12" | |
| oxis="16" | |
| lines="12" | |
| ## The `+ 1`'s account for the Zopfli variants adding an | |
| ## extra '$oxis' for brute and non-brute variants each | |
| total_lines="$(( | |
| (zcs + 1) * (lines * levels + 1) * oxis | |
| ))" | |
| [ -z "${quant_src:-}" ] || | |
| total_lines="$(( total_lines * 2 ))" | |
| else | |
| smallest_known="$src_size" | |
| ## At minimum, the 3 `gifsicle` trials are attempted | |
| total_lines="3" | |
| [ -n "$NO_WEBP" ] || total_lines="$(( total_lines + 1 ))" | |
| [ -n "$NO_APNG" ] || total_lines="$(( total_lines + 6 ))" | |
| fi | |
| # Remount '$work' with a high enough `nr_inodes` limit | |
| ## '16' is a random, safe number to ensure non-`xargs` files can exist | |
| mount --options remount,nosuid,nodev,noexec,size=50%,nr_inodes="$(( total_lines + 16 ))" \ | |
| "$work"/ || pquit "Failed to remount '$work/'!" | |
| # Create the script `xargs` will execute | |
| tmp_oxi="$work/oxi" | |
| gen_script >"$tmp_oxi" | |
| # Parse & execute each line in the command list | |
| ## `9>&2` redirects the script's STDERR to FD 2, while redirecting the | |
| ## STDERR of `xargs` to `/dev/null`. `exit 255` causes the STDERR logs. | |
| ### `cat --number` indexes the lines for progress tracking | |
| gen_list | cat --number | xargs \ | |
| --max-procs "$MAX_PROCS" \ | |
| --delimiter "\n" \ | |
| --replace="%" \ | |
| -- sh "$tmp_oxi" \ | |
| "$smallest_known" \ | |
| "$total_lines" \ | |
| "${oxipng_base:-}" \ | |
| "%" \ | |
| 9>&2 2>/dev/null || | |
| exit_clear | |
| clear_prog | |
| ## The script isn't useful anymore, and `find` will see it, so remove it | |
| rm "$tmp_oxi" || pquit "Failed to remove '$tmp_oxi'!" | |
| # Optimise the `gif2apng` outputs with this script | |
| ## ...unless told otherwise | |
| [ -z "$NO_APNG" ] || return 0 | |
| ## Allow globbing for just these for-loops | |
| set +f | |
| ## De-duplicate the APNGs beforehand | |
| for dup in /tmp/gif2apng*; do | |
| [ -f "$dup" ] || continue | |
| [ ! -L "$dup" ] || continue | |
| for dup2 in /tmp/gif2apng*; do | |
| [ "$dup" != "$dup2" ] || continue | |
| ! cmp "$dup" "$dup2" >/dev/null 2>&1 || | |
| ln --force --symbolic "$dup" "$dup2" 2>/dev/null || | |
| perr "Failed to symlink '$dup2' to '$dup'!" | |
| done | |
| done | |
| for apngsrc in /tmp/gif2apng*; do | |
| ## Don't optimise duplicate SRC images; the output is identical | |
| if [ -L "$apngsrc" ]; then | |
| printf "'%s' and '%s' are identical. Skipping." \ | |
| "$apngsrc" "$(readlink -- "$apngsrc")" | | |
| pstat "APNG ⇦ " | |
| continue | |
| fi | |
| [ -f "$apngsrc" ] || continue | |
| apngdest="${apngsrc%.*}.oxipng.apng" | |
| ## Each `sisyphus` instance has its own '$work' directory; | |
| ## therefore, pass the input as a file descriptor | |
| exec 4<"$apngsrc" | |
| apngsrc_real="$apngsrc" | |
| apngsrc="/proc/self/fd/4" | |
| pstat "APNG ← " "Optimising '$apngsrc_real'…" | |
| ## `--stdout` implies `--quiet` | |
| "$0" --_internal --max-procs "$MAX_PROCS" --no-webp \ | |
| --all-oxi "${ALL_OXI:-"1"}" --stdout -- "$apngsrc" \ | |
| >"$apngdest" ||: | |
| ## If '$apngdest' is still empty, substitute it with '$apngsrc' | |
| if [ -f "$apngdest" ] && [ ! -s "$apngdest" ]; then | |
| ln --force --symbolic "$apngsrc_real" "$apngdest" \ | |
| 2>/dev/null || | |
| perr "Failed to symlink '$apngdest' to '$apngsrc_real'!" | |
| fi | |
| done | |
| set -f | |
| } | |
| optimise | |
| # Output listing & selection | |
| tmp_smallest="$work/smallest" | |
| ## 0. Gather all the results, while ignoring any possible empty files that can | |
| ## be created by failed APNG optimisations. | |
| ## 1. Prefix lines with their length followed by a space, e.g. '20 8 ...'. | |
| ## 2. Sort the lines by their size (2n) and then by their length (1V). | |
| ## 3. Remove the first, length field (2-), e.g. '8 ...'. | |
| find "$work"/ -maxdepth 1 -follow -type f -size +0c -printf "%s\t%f\n" | | |
| awk '{ printf "%d\t%s\n", length, $0 }' | | |
| sort --field-separator=" " --key="2n" --key="1V" | { | |
| # | Start of the inside of this pipe | | |
| ## Read the first line from STDIN, which will be for the smallest result, while | |
| ## preserving the lines following it | |
| IFS=" " read -r _ smallest_size smallest | |
| smallest_read="$smallest_size $smallest" | |
| if [ "$RESULTS" != "0" ]; then | |
| pstat "Size order ↯ " "" | |
| ## Make sure `cut` & `sed` only filter the results by using `{}` | |
| { | |
| ## Print the smallest result | |
| ### `0\t` is a placeholder for the length | |
| printf "0\t%s\n" "$smallest_read" | |
| ## Print the remaining results | |
| if [ "$RESULTS" = "ALL" ]; then | |
| cat | |
| else | |
| ## The smallest result was already printed, so | |
| ## print one fewer lines than specified | |
| head --lines="$(( RESULTS - 1 ))" | |
| fi | |
| } | | |
| cut --delimiter=" " --fields=2- | | |
| sed "s/\t/ bytes\t/g ; s/^/\t/g" >&2 | |
| ## This will only be printed if '--quiet' is omitted; it redirects FD 1 | |
| printf "\n" | |
| fi | |
| ## Variables set inside pipelines are isolated from the script, so write the | |
| ## smallest result to a file. | |
| ### Do the write at the end in case `find` tries to include '$tmp_smallest'. | |
| ### After the RESULTS-ALL if-statement, it wouldn't matter if it were found | |
| printf "%s\n" "$smallest_read" >"$tmp_smallest" | |
| # | End of the inside of this pipe | | |
| } | |
| ## Read '$tmp_smallest' for the best known result | |
| IFS=" " read -r smallest_size smallest <"$tmp_smallest" || | |
| pquit "Failed to read the smallest result from '$tmp_smallest'!" | |
| smallest="$work/$smallest" | |
| [ -f "$smallest" ] || | |
| pquit "'$smallest' is the best result, but it doesn't exist or isn't a file!" | |
| ## Check for an improvement | |
| [ "$smallest_size" -le "${SIZE:-"$smallest_size"}" ] || | |
| printf "'%s' is equal to, or larger than, the minimum size target: %d bytes." \ | |
| "$smallest" "$SIZE" | pquit | |
| if [ "$smallest_size" -eq "$src_size" ]; then | |
| pstat "\033[32mAlready optimal ✓ " "The input is as small as the best result!" | |
| ## '--quiet' redefines `osc777()` to be `true`; this is already handled | |
| osc777 "'$src_real' is already optimal!" | |
| exit 0 | |
| fi | |
| [ "$smallest_size" -le "$src_size" ] || | |
| pquit "'$smallest' is larger than '$src_real'." | |
| ## Symlinks also have an apparent size of '0'; exclude them from this check | |
| if [ "$(stat --format "%b" -- "$smallest")" -le 0 ] && [ ! -L "$smallest" ]; then | |
| pquit "'$smallest' was truncated at some point; it cannot be used!"; fi | |
| ## Output the best result (unless '--dry-run' was specified) | |
| if [ -n "$STDOUT" ]; then | |
| ## '--stdout' implies '--quiet'; therefore: | |
| ### FD 8 prints to STDOUT | |
| [ -n "$DRY" ] || cat "$smallest" >&8 | |
| ### And, the proceeding steps are irrelevant | |
| exit 0 | |
| fi | |
| final_dest="$dest_path.${smallest##*.}" | |
| if [ -e "$final_dest" ] && [ -z "$FORCE" ]; then | |
| pquit "'$final_dest' already exists!"; fi | |
| [ -n "$DRY" ] || cp -- "$smallest" "$final_dest" | |
| ## Print the final statistics. | |
| ### "> ": as in the "quant > ..." prefix of the filename | |
| method="${smallest##*"> "}" | |
| method="${smallest##*"/"}" | |
| method="${method%.*}" | |
| printf "%s \033[2m(%s)" "$final_dest" "$method" | pstat "Final output → " | |
| printf "%d bytes \033[2m↘\033[0;39m %d bytes" "$src_size" "$smallest_size" | | |
| pstat "Size diff ↘ " | |
| osc777 "'$final_dest' is finished! Size diff: $src_size bytes -> $smallest_size bytes ($method)" |
Author
Sometimes running FO multiple times on the same file (Shift+F5,
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Found these (I'll edit the previous samples if I find anything for them too):
FO: 388 bytes
FO > Sisyphus: 387 bytes (
zop + nb.png. Regularzop.pngmatches FO, and every permutation ofzop + *.pngbeats it by a single byte, so it's not just--opt maxhere)FO: 2987 bytes (FO > FO: Same size)
FO > Sisyphus: 2956 bytes (
zop.png. Probably--opt maxagain)FO > Sisyphus > FO: 2955 bytes (???)