-
-
Save Winterhuman/21d7b148db40ff041f397b07a7aafb83 to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S unshare --pid --mount-proc --kill-child --map-root-user /bin/sh | |
# Licensed under the Zero-Clause BSD terms: https://opensource.org/license/0bsd | |
## Requires pngquant, oxipng, gifsicle, gif2apng, and optionally libwebp & | |
## 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 "\033[1m\033[31m%b\033[0;39m" "$1" >&2; } | |
pquit() { perr "$1"; exit 1; } | |
pstat() { printf "\033[1m\033[34m%b\033[0;39m\033[1m%b\033[0;39m" "$1" "$2"; } | |
## `kill` handles background jobs, but `exit` is required for normal processes | |
trap 'kill "$$"; exit 1' INT | |
help() { | |
cat << "HELP" | |
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 takes a boolean value. Controls whether to always, | |
or never, try all OxiPNG variations. If unset, heuristically | |
determine whether to try every OxiPNG variation. | |
-f, --force Overwrite existing destination files. | |
-n, --no-webp Never optimise with CWebP. | |
-s, --size <int> Takes an integer (bytes). Sets the minimum qualifying size. | |
If this size isn't met or surpassed, DEST is not created. | |
-h, --help Display this help message, and then exit. | |
Warning: | |
HDR SRC images will NOT be losslessly optimised! | |
HELP | |
exit 0 | |
} | |
# 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' | |
mount -t tmpfs -o nosuid,nodev,noexec,size=50%,nr_inodes=1349 sisyphus /tmp || | |
pquit "Failed to overmount '/tmp/'!\n" | |
# Argument parsing | |
ALL_OXI="" | |
SIZE="" | |
FORCE="" | |
NO_WEBP="" | |
SKIP="" | |
ARG_COUNT="0" | |
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 | |
;; | |
-s|--size) | |
case "${2:-""}" in | |
[0-9]*) SIZE="$2"; shift ;; | |
*) pquit "No positive integer was given for '-s|--size'!\n" ;; | |
esac | |
;; | |
-f|--force) FORCE="1" ;; | |
-n|--no-webp) NO_WEBP="1" ;; | |
-h|--help) help ;; | |
--) SKIP="1" ;; | |
-*) pquit "'$1' is not a known option!\n" ;; | |
*) 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, so always use `||` | |
[ "$#" -le 2 ] || pquit "Too many arguments given!\n" | |
# Don't try WebP if `libwebp` isn't installed | |
command -v cwebp >/dev/null || NO_WEBP=1 | |
### SRC | |
src_real="${1:?$(pquit "No source image file given!\n")}" | |
[ -s "$src_real" ] || pquit "'$src_real' is not a non-empty file!\n" | |
## Assign the SRC a file descriptor | |
exec 3<"$src_real" | |
src="/proc/self/fd/3" | |
src_real="$(realpath -- "$src_real")" | |
## Size detection | |
find_size() { | |
if [ -f "$1" ]; then | |
wc -c <"$1" || pquit "Failed to find size of '$1'!\n" | |
else | |
printf "99999999999999" | |
fi | |
} | |
src_size="$(find_size "$src")" | |
if [ -n "$SIZE" ]; then | |
OLD_SIZE="$SIZE" | |
## Strip leading zeros, and avoid integer overflow | |
SIZE="$(printf "%s" "$SIZE" | sed "s/^0*//")" | |
## '0' would be trimmed to nothing by `sed`, so reset it | |
SIZE="${SIZE:-0}" | |
printf "%d" "$SIZE" >/dev/null 2>&1 || | |
pquit "The value given for '-s|--size', '$OLD_SIZE', is not an integer!\n" | |
[ "$SIZE" -eq "$(printf "%s" "$SIZE" | cut -c -12)" ] || | |
pquit "The value given for '-s|--size', '$OLD_SIZE', is over the integer limit!\n" | |
fi | |
## Mimetype detection | |
mime="$( | |
file -Lib -- "$src" || | |
pquit "Couldn't determine mimetype of '$src_real'!\n" | |
)" | |
mime="${mime%;*}" | |
### DEST | |
dest_path="${2:-""}" | |
dest_path="${dest_path%.*}" | |
dest_path="${dest_path:-${src_real%.*}.new}" | |
dest_path="$(realpath -- "$dest_path")" | |
dest_exists() { | |
[ -z "$FORCE" ] || return 0 | |
## Allow globbing & unset-variables for just this for-loop | |
set +fu | |
for similar in "$1"*; do | |
if [ -e "$similar" ] && [ "${similar%.*}" = "$1" ]; then | |
perr "'$similar' shares a filename with '$1'!\n" | |
return 1 | |
fi | |
done | |
set -fu | |
} | |
dest_exists "$dest_path" || exit 1 | |
pstat "Input:\t\t\t" "$src_real \033[37m($mime)\033[0;39m\n" | |
pstat "Output template:\t" "${dest_path}.???\n" | |
[ -z "$SIZE" ] || pstat "Size target:\t\t" "${SIZE} bytes\n" | |
## Misc variables | |
delimiter="> " | |
# Optimisation | |
keep_if_smaller() { | |
## Truncate '$file' to retain its perceived size, without allocating | |
## any real blocks to it, if it can't meet the requirement | |
file="$1" | |
req1="$2" | |
req2="$3" | |
if [ ! -f "$file" ]; then | |
perr "'$file' was truncated, but, it didn't originally exist!\n" | |
return 1 | |
fi | |
if [ "$req1" -lt "$req2" ]; then | |
file_size="$(find_size "$file")" | |
truncate --size=0 "$file" | |
truncate --size="$file_size" "$file" | |
fi | |
} | |
## In case of PNG | |
run_oxi() { | |
## The '$oxi_safe' variable controls APNG mode | |
oxi_src="$oxi_perm_src" | |
oxi_ext="$1" | |
oxi_dest="${2}.${3:+"a"}png" | |
oxi_safe="${3:-""}" | |
## Pass arguments 4 and beyond as-is to `oxipng` to avoid word splitting | |
shift 3 | |
if ! oxipng "$@" --opt max --strip "${oxi_safe:-"all"}" --alpha \ | |
--out "$oxi_dest" -- "$oxi_src" >/dev/null 2>&1; then | |
perr "Passing '$oxi_src' through 'oxipng' ($oxi_ext) failed!\n" | |
return 1 | |
fi | |
oxi_dest_size="$(find_size "$oxi_dest")" | |
keep_if_smaller "$oxi_dest" \ | |
"$smallest_known" "$oxi_dest_size" | |
} | |
oxi_permutations() { | |
# Try every permutation of `--[nb,nc,ng,np]` | |
oxi_perm_src="$1" | |
oxi_perm_ext="$2" | |
oxi_perm_dest="$3" | |
oxi_perm_safe="${4:-""}" | |
shift 4 | |
## Skip `--zc=12` without extra options (it already exists for the | |
## heuristic that was tested earlier) | |
if [ "$*" != "--zc=12" ] || | |
[ "$oxi_perm_src" = "${src_quant:-""}" ]; then | |
run_oxi "$oxi_perm_ext" \ | |
"${oxi_perm_dest}${oxi_perm_ext}" \ | |
"$oxi_perm_safe" \ | |
"$@" | |
fi | |
run_oxi "$oxi_perm_ext + nb" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb | |
run_oxi "$oxi_perm_ext + nb + nc" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + nc" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --nc | |
run_oxi "$oxi_perm_ext + nb + ng" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + ng" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --ng | |
run_oxi "$oxi_perm_ext + nb + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --np | |
run_oxi "$oxi_perm_ext + nb + nc + ng" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + nc + ng" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --nc --ng | |
run_oxi "$oxi_perm_ext + nb + nc + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + nc + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --nc --np | |
run_oxi "$oxi_perm_ext + nb + ng + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + ng + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --ng --np | |
run_oxi "$oxi_perm_ext + nb + nc + ng + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nb + nc + ng + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --nb --nc --ng --np | |
run_oxi "$oxi_perm_ext + nc" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nc" \ | |
"$oxi_perm_safe" \ | |
"$@" --nc | |
run_oxi "$oxi_perm_ext + nc + ng" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nc + ng" \ | |
"$oxi_perm_safe" \ | |
"$@" --nc --ng | |
run_oxi "$oxi_perm_ext + nc + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nc + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --nc --np | |
run_oxi "$oxi_perm_ext + nc + ng + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + nc + ng + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --nc --ng --np | |
run_oxi "$oxi_perm_ext + ng" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + ng" \ | |
"$oxi_perm_safe" \ | |
"$@" --ng | |
run_oxi "$oxi_perm_ext + ng + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + ng + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --ng --np | |
run_oxi "$oxi_perm_ext + np" \ | |
"${oxi_perm_dest}${oxi_perm_ext} + np" \ | |
"$oxi_perm_safe" \ | |
"$@" --np | |
} | |
oxi_all() { | |
tmp_dest_path="/tmp/${2:-""}" | |
tmp_safe="${4:-""}" | |
## Keep `--zopfli` separate from the other permutations due to its cost | |
if [ -z "${3:-""}" ]; then | |
zc=0 | |
while [ "$zc" -le 12 ]; do | |
oxi_permutations \ | |
"$1" \ | |
"zc${zc}" \ | |
"$tmp_dest_path" \ | |
"$tmp_safe" \ | |
--zc="$zc" & | |
zc="$(( zc + 1 ))" | |
done | |
wait | |
else | |
## Use 'zop' as the extension so 'zcN' is ordered before | |
oxi_permutations "$1" "zop" "$tmp_dest_path" "$tmp_safe" \ | |
--zopfli --zi=255 | |
fi | |
} | |
src_is_png() { | |
# Create CWebP and baseline OxiPNG outputs, for use as a heuristic | |
cwebp_dest="/tmp/cwebp.webp" | |
oxi_baseline_dest="/tmp/zc12.png" | |
if [ "$NO_WEBP" != 1 ]; then | |
## Passing `-q` or `-m` disables lossless mode | |
cwebp -quiet -mt -z 9 -alpha_filter best -o "$cwebp_dest" \ | |
-- "$src" || | |
perr "Passing '$src' through 'cwebp' failed!\n" | |
fi & | |
oxipng --opt max --strip all --alpha --out "$oxi_baseline_dest" \ | |
-- "$src" >/dev/null 2>&1 || | |
perr "Passing '$src' through 'oxipng' (zc12) failed!\n" & | |
wait | |
# Skip trying the OxiPNG variants if explicitly requested | |
[ "$ALL_OXI" != "0" ] || return 0 | |
oxi_baseline_size="$(find_size "$oxi_baseline_dest")" | |
cwebp_size="$(find_size "$cwebp_dest")" | |
fraction="$(( ( oxi_baseline_size * 9 ) / 10 ))" | |
smallest_heuristic="$(( | |
oxi_baseline_size < cwebp_size | |
? oxi_baseline_size | |
: cwebp_size | |
))" | |
## '$smallest_known' is read by `run_oxi()` | |
if [ -n "$SIZE" ]; then | |
smallest_known="$(( | |
smallest_heuristic < SIZE | |
? smallest_heuristic | |
: SIZE | |
))" | |
else | |
smallest_known="$(( | |
smallest_heuristic < src_size | |
? smallest_heuristic | |
: src_size | |
))" | |
fi | |
# Try the OxiPNG variants if: | |
# 1. Explicitly requested. | |
# 2. Else, if the baseline OxiPNG's size is less than 1KiB. | |
# 3. Else, if the input image is still the smallest known encoding. | |
# 4. Else, if the CWebP size isn't smaller than 90% of the baseline. | |
if [ "$ALL_OXI" = 1 ] || | |
[ "$oxi_baseline_size" -le 1024 ] || | |
[ "$src_size" -le "$smallest_heuristic" ] || | |
[ "$fraction" -le "$cwebp_size" ]; then | |
oxi_all "$src" & | |
src_quant="/tmp/quant.png" | |
if pngquant --quality 100-100 --speed 1 --strip \ | |
--output "$src_quant" -- "$src"; then | |
oxi_all "$src_quant" "quant $delimiter" & | |
fi | |
wait | |
## Execute the `--zopfli` permutations last & sequentially | |
pstat "" "\tBeginning Zopfli trials, \033[33mthis may take a while...\n" | |
oxi_all "$src" "" "zop" | |
[ ! -f "$src_quant" ] || | |
oxi_all "$src_quant" "quant $delimiter" "zop" | |
fi | |
} | |
## In case of GIF | |
run_apng() { | |
apng_src="$1" | |
apng_ext="$2" | |
apng_dest="$3" | |
## Pass arguments 4 and beyond as-is to `gif2apng` to avoid word splitting | |
shift 3 | |
apng_dest_noext="${apng_dest} + ${apng_ext}" | |
apng_dest_ext="${apng_dest_noext}.apng" | |
gif2apng "$@" -i20 -- "$apng_src" "$apng_dest_ext" \ | |
>/dev/null 2>&1 || | |
perr "Passing '$apng_src' through 'gif2apng' ($apng_ext) failed!\n" | |
## `gif2apng` doesn't remove all unnecessary metadata, so do that here | |
exiftool -overwrite_original_in_place -all= "$apng_dest_ext" \ | |
>/dev/null 2>&1 || | |
perr "Failed to remove EXIF metadata from '$apng_dest_ext'!\n" | |
## Convert the relative paths back to absolute paths, for neatness | |
apng_oxi_src="$(realpath -- "$apng_dest_ext")" | |
apng_oxi_src_size="$(find_size "$apng_oxi_src")" | |
apng_oxi_dest_noext="$(realpath -- "$apng_dest_noext")" | |
smallest_heuristic="$apng_oxi_src_size" | |
## '$smallest_known' is read by `run_oxi()` | |
if [ -n "$SIZE" ]; then | |
smallest_known="$(( | |
smallest_heuristic < SIZE | |
? smallest_heuristic | |
: SIZE | |
))" | |
else | |
smallest_known="$(( | |
smallest_heuristic < src_size | |
? smallest_heuristic | |
: src_size | |
))" | |
fi | |
# Skip trying the OxiPNG variants if explicitly requested, and create a | |
# basic OxiPNG output instead | |
if [ "$ALL_OXI" = 0 ]; then | |
baseline_apng="${apng_oxi_dest_noext} ${delimiter}zc12.apng" | |
oxipng --opt max --strip safe --alpha --out "$baseline_apng" \ | |
-- "$apng_oxi_src" >/dev/null 2>&1 || | |
perr "Passing '$apng_oxi_src' through 'oxipng' (zc12) failed!\n" | |
baseline_apng_size="$(find_size "$baseline_apng")" | |
keep_if_smaller "$baseline_apng" \ | |
"$smallest_known" "$baseline_apng_size" | |
return 0 | |
fi | |
# Try the OxiPNG variants if: | |
# 1. Explicitly requested. | |
# 2. Else, if the APNG SRC image is less than 1KiB. | |
# 3. Else, if the input image is still the smallest known encoding. | |
## A heuristic for when to try the OxiPNG variants, based on a size | |
## comparison between a baseline OxiPNG output and the WebP output size, | |
## hasn't been determined for animated images yet | |
if [ "$ALL_OXI" = 1 ] || | |
[ "$apng_oxi_src_size" -le 1024 ] | |
[ "$src_size" -le "$smallest_heuristic" ]; then | |
## Passing "safe" here sets `--strip safe` for `oxipng`, | |
## which preserves APNG animation frames in the metadata | |
oxi_all \ | |
"$apng_oxi_src" \ | |
"${apng_oxi_dest_noext##*/} ${delimiter}" \ | |
"" "safe" | |
## Execute the `--zopfli` permutations last | |
pstat "" "\tBeginning Zopfli trials for '${apng_oxi_dest_noext##*/}'...\n" | |
oxi_all \ | |
"$apng_oxi_src" \ | |
"${apng_oxi_dest_noext##*/} ${delimiter}" \ | |
"zop" "safe" | |
else | |
keep_if_smaller "$apng_oxi_src" \ | |
"$smallest_known" "$apng_oxi_src_size" | |
fi | |
} | |
toapng() { | |
# GIF2APNG can't handle absolute paths, so make them relative | |
# Source: https://sourceforge.net/p/gif2apng/discussion/1022150/thread/8ec5e7e288 | |
apng_src="$(realpath --relative-to="$PWD" -- "$src")" | |
tmp_dest="$(realpath --relative-to="$PWD" -- "/tmp/gif2apng")" | |
## Try every permutation of `-[z[0-2],kc,kp]`, and their oxi-variants | |
run_apng "$apng_src" "z0" "$tmp_dest" -z0 | |
run_apng "$apng_src" "z1" "$tmp_dest" -z1 | |
run_apng "$apng_src" "z2" "$tmp_dest" -z2 | |
run_apng "$apng_src" "z0 + kp" "$tmp_dest" -z0 -kp | |
run_apng "$apng_src" "z1 + kp" "$tmp_dest" -z1 -kp | |
run_apng "$apng_src" "z2 + kp" "$tmp_dest" -z2 -kp | |
} | |
togif() { | |
gifsicle_dest="/tmp/gifsicle + o${1}.gif" | |
gifsicle --optimize="$1" --optimize=keep-empty --threads="$(nproc)" \ | |
-o "$gifsicle_dest" -- "$src" || | |
pquit "Passing '$src' through 'gifsicle --optimize=\"${1}\"' failed!\n" | |
gifsicle_dest_size="$(find_size "$gifsicle_dest")" | |
keep_if_smaller "$gifsicle_dest" "$smallest_known" "$gifsicle_dest_size" | |
} | |
towebp() { | |
[ "$NO_WEBP" != 1 ] || return 0 | |
webp_dest="/tmp/cwebp.webp" | |
gif2webp -quiet -mt -min_size -m 6 -q 100 -metadata none \ | |
-o "$webp_dest" -- "$src" 2>/dev/null || | |
pquit "Passing '$src' through 'gif2webp' failed!\n" | |
webp_dest_size="$(find_size "$webp_dest")" | |
keep_if_smaller "$webp_dest" "$smallest_known" "$webp_dest_size" | |
} | |
src_is_gif() { | |
if [ -n "$SIZE" ]; then | |
smallest_known="$SIZE" | |
else | |
smallest_known="$src_size" | |
fi | |
togif 3 & | |
togif 2 & | |
togif 1 & | |
towebp & | |
wait | |
pstat "" "\tBeginning 'gif2apng' trials, \033[33mthis may take a while...\n" | |
toapng | |
} | |
# File selection | |
case "$mime" in | |
"image/png") src_is_png ;; | |
"image/gif") src_is_gif ;; | |
*) pquit "Mimetype of '$src_real' is neither PNG nor GIF!\n" ;; | |
esac | |
## Allow globbing & unset-variables for just this for-loop | |
set +fu | |
for tmp_file in /tmp/*; do | |
[ ! -f "$tmp_file" ] || | |
sizepathlist="$(find_size "$tmp_file")\t${tmp_file}\n$sizepathlist" | |
done | |
set -fu | |
## 1. Prefix lines with their length followed by a space (e.g. '20 8 bytes ...'). | |
## 2. Sort the lines by their length values (aka. `sort -n`). | |
## 3. Use space as the delimiter (aka. '-d " "'), and remove everything from | |
## before the second field (e.g. '8 bytes ...'). | |
## 4. Sort numerically, but for cases where the byte count is the same, preserve | |
## the ordering from the previous steps (aka. `sort -sn`). | |
orderedlist="$( | |
printf "%b" "$sizepathlist" | | |
awk '{ print length, $0 }' | | |
sort -n | | |
cut -d " " -f 2- | | |
sort -sn | |
)" | |
## The second sed pattern uses '|' delimiters since '/' is used in the path | |
fancy_orderedlist="$( | |
printf "%b" "$orderedlist" | | |
sed -e "s/\t/ bytes\t/g;s|/tmp/||g;s/^/\t/g" | |
)" | |
pstat "Size order:\n" "$fancy_orderedlist\n\n" | |
# Output selection | |
smallest="$( | |
printf "%b" "$orderedlist" | | |
head -n1 | | |
cut -d " " -f 2- | |
)" | |
smallest_size="$(find_size "$smallest")" | |
if [ -n "$SIZE" ]; then | |
[ "$smallest_size" -le "$SIZE" ] || | |
pquit "The smallest output was greater than the minimum size target.\n" | |
else | |
[ "$smallest_size" -lt "$src_size" ] || | |
pquit "The smallest output was greater or equal in size to the input.\n" | |
fi | |
[ "$(stat --format "%b" -- "$smallest")" -gt 0 ] || | |
pquit "The smallest output was truncated at some point!\n" | |
final_dest="${dest_path}.${smallest##*.}" | |
if [ -e "$final_dest" ] && [ -z "$FORCE" ]; then | |
pquit "'$final_dest' already exists!\n"; fi | |
mv -- "$smallest" "$final_dest" || | |
pquit "Failed to move '$smallest' to '$final_dest'!\n" | |
delimiter="${delimiter:-""}" | |
method="${smallest##*"$delimiter"}" | |
method="${smallest##*"/"}" | |
method="${method%.*}" | |
pstat "Final output:\t" "$final_dest \033[37m($method)\033[0;39m\n" | |
pstat "Size diff:\t" \ | |
"$src_size bytes \033[37m->\033[0;39m \033[1m$smallest_size bytes\n" |
I'm not able to test it since it's an EXE, but, since I think it's still using OptiPNG under the hood, I would guess my script is better for now (OptiPNG always faired worse than OxiPNG from my testing). The script currently tries every valid, unordered permutation of:
[pngquant --quality 100-100 --speed 1 --strip |,] oxipng [--zopfli --zi=255,--zc={0..12}] --n[bcgp] --opt max --strip all --alpha
If, when FileOptimizer switches to OxiPNG, it additonally executes oxipng
with some other options beyond these (which I'm not aware of currently), or it implements a palette randomiser itself (which is what pngquant
functions as here), it could beat this script.
Per the current source @ https://sourceforge.net/p/nikkhokkho/code/HEAD/tree/trunk/FileOptimizer/Source/cppMain.cpp#l2087, FO currently uses "apngopt, pngquant, PngOptimizer, TruePNG, pngout, oxipng, pngwolf, Leanify, ect, pingo, advpng, deflopt, defluff, deflopt" (not a typo, runs deflopt 2× based on testing) in the PNG toolchain, w/ configurable options via GUI & INI.
FO reportedly works fine under WINE & emulators. Perhaps you could compare that way?
Managed to use Bottles to run FO, and... inconclusive? Here's what I'm noticing:
- Sisyphus can beat FO, and vice-versa, so neither is guaranteed to win unfortunately.
- From that
cppMain.cpp
file you linked, I'm noticing thatoxipng -Z
is always being used, however, Zopfli can actually lose to non-Zopfli compression (and also, there's no option permutation like I'm doing here, which can also help). pingo
is able to beatcwebp
sometimes, which is unfortunate sincepingo
is also a Windows-only executable, so this script is definitely missing out in those cases.
I haven't tested GIFs or APNGs yet, and I also need to update this script with my new APNG brute-forcing pipeline, but from the above results, I'm guessing that's going to be a mixed bag as well.
EDIT: I've updated Sisyphus with the new APNG stuff, along with some other changes.
Thanks for the detailed comparison! If you've test files you'd be willing to make available that Sisyphus beats FO, that'd be much appreciated. 🙇🏾♂️
Pinging @javiergutierrezchamorro (the FO dev) to take note of above feedback.
Here's some samples:
Sisyphus: 1824 bytes (zop.png
. I threw these first two samples in to show that FO can beat Sisyphus)
FO: 1823 bytes
Sisyphus: 479 bytes (quant > zop + nb.png
)
FO: 474 bytes
[FO >] Sisyphus: 413 bytes (zop.png
. It's probably --opt max
that makes the difference with these zop.png
results)
FO: 414 bytes
Sisyphus: 534 bytes (zop.png
)
FO: 537 bytes
FO > Sisyphus: 536 bytes (zop.png
)
[FO >] Sisyphus: 246 bytes (quant > zop.png
. Regular zop.png
matched FO, so pngquant
did something here)
FO: 247 bytes
Sisyphus: 3396 bytes (zop.png
)
FO: 3398 bytes
I forgot to set the optimisation level to 9 in FO... one moment.
EDIT: Done
Just to make sure I understand, just the last example beats FO (by 1 byte)? 🤔
Yeah, I had some other images going before I realised I forgot the level 9 setting; will edit with some of the other examples when I have time.
EDIT: Okay, so bad news. Pretty much every time Sisyphus yields a smaller result, it's because it uses --zopfli
and --opt max
, which FO doesn't. Except for that quant > zop.png
result, where regular zop.png
matched FO, so the extra palette randomisation that pngquant
introduces can help on rare occasions.
But yeah, ever since I set the level in FO's settings correctly, I've been struggling to beat it; it's usually a tie or loss save for these samples. Welp.
1 tip: I think you're pretty used to long processing times b/c of your script, but, if you've the spare processing power, multiple instances of FO work quite well on split file lists to speed up overall processing.
Also, does running Sisyphus before/after FO-processed images improve anything?
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
. Regular zop.png
matches FO, and every permutation of zop + *.png
beats it by a single byte, so it's not just --opt max
here)
FO: 2987 bytes (FO > FO: Same size)
FO > Sisyphus: 2956 bytes (zop.png
. Probably --opt max
again)
FO > Sisyphus > FO: 2955 bytes (???)
Sometimes running FO multiple times on the same file (Shift+F5,
@Winterhuman How does this compare to, e.g., FileOptimizer?