Skip to content

Instantly share code, notes, and snippets.

@Winterhuman
Last active March 4, 2025 22:20
Show Gist options
  • Save Winterhuman/21d7b148db40ff041f397b07a7aafb83 to your computer and use it in GitHub Desktop.
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).
#!/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"
@TPS
Copy link

TPS commented Feb 27, 2025

Also, does running Sisyphus before/after FO-processed images improve anything?

@Winterhuman
Copy link
Author

Winterhuman commented Feb 27, 2025

Found these (I'll edit the previous samples if I find anything for them too):

general-fo

FO: 388 bytes

general-fo-sisyphus

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)


balloon-fo

FO: 2987 bytes (FO > FO: Same size)

balloon-fo-sisyphus

FO > Sisyphus: 2956 bytes (zop.png. Probably --opt max again)

balloon-fo-sisyphus-fo

FO > Sisyphus > FO: 2955 bytes (???)

@TPS
Copy link

TPS commented Feb 27, 2025

Sometimes running FO multiple times on the same file (Shift+F5,▶️) back-to-back gives better results, too, so that's kind-of expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment