Skip to content

Instantly share code, notes, and snippets.

@Winterhuman
Last active October 23, 2023 22:28
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).
#!/bin/sh
# Licensed under the Zero-Clause BSD terms: https://opensource.org/license/0bsd
# Requires pngquant, oxipng, gifsicle, gif2apng, libwebp, and optionally perl-image-exiftool.
perr() { printf "\033[1m\033[31m%b\033[0;39m" "$1"; }
pquit() { perr "$1"; exit 1; }
pstat() { printf "\033[1m\033[34m%b\033[0;39m\033[1m%b\033[0;39m\n" "$1" "$2"; }
clean() { if ! rm -r "$tmp"; then pquit "Failed to delete '$tmp'!\n"; fi }
trap clean EXIT
tmp="$(mktemp -d)"
# Input
if [ -z "$1" ]; then
pquit "No arguments given!\n"; fi
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
# '$COLUMNS' is a shell variable, and not environmental, so it isn't
# passed down to child processes.
# The 'stty' approach is cross-platform as of 23-04-2020 according to:
# https://austingroupbugs.net/view.php?id=1053
COLUMNS="$(stty size | cut -d " " -f 2-)"
MAX_WIDTH="$(( 100 > COLUMNS ? COLUMNS : 100 ))"
pstat "Arguments:" ""
printf "\t\033[1m1:\033[0;39m /path/to/input{.png,.gif}\n"
printf "\t\033[1m2:\033[0;39m /path/to/output{.png,.webp,.gif,.apng}\n"
printf "\t\t\033[1mNote:\033[0;39m The output's outermost filename extension is replaced with the ideal format's extension.\n\n" | fmt -w "$MAX_WIDTH"
pstat "Environment variables:" "" | fmt -w "$MAX_WIDTH"
printf "\t\033[1mSISYPHUS_NO_WEBP=1\033[0;39m\n\t\tNever output a WebP file (though it's still included in the size list).\n\n" | fmt -w "$MAX_WIDTH"
printf "\t\033[1mSISYPHUS_OXI_VAR\033[0;39m\n"
printf "\t\tIf set to 0, never try the OxiPNG variants.\n" | fmt -w "$MAX_WIDTH"
printf "\t\tIf set to 1, always try the variants.\n" | fmt -w "$MAX_WIDTH"
printf "\t\tOtherwise, the script will heuristically determine whether to try the variants.\n" | fmt -w "$MAX_WIDTH"
exit 0
fi
if [ ! -f "$1" ]; then
pquit "'$1' doesn't exist, or isn't a file!\n"; fi
input="$1"
find_size() {
if [ ! -f "$1" ]; then printf "999999999999"; else
if ! wc -c < "$1"; then
pquit "Failed to find size of '$1'!\n"; fi
fi
}
input_size="$(find_size "$input")"
# Mimetype detection
find_mime() {
if ! file -ib "$1"; then
pquit "Couldn't determine mimetype of '$1'!\n"; fi
}
mime="$(find_mime "$input")"
mime="${mime%;*}"
pstat "Input:\t\t" "$input \033[37m($mime)\033[0;39m"
# Output
if [ -z "$2" ]; then
pquit "No output given!\n"; fi
given_output="${2%.*}"
output_filename="${given_output##*/}"
pstat "Output format:\t" "${given_output}.???"
for output_exists in "${2%.*}"*; do
if [ -f "$output_exists" ] && [ "${output_exists%.*}" = "${2%.*}" ]; then
pquit "'$output_exists' shares a filename with '$2'!\n"; fi
done
# In case of (A)PNG
run_cwebp() {
# Skip WebP output if '$SISYPHUS_NO_WEBP' is set to 1.
if [ "$SISYPHUS_NO_WEBP" = 1 ]; then exit 0; fi
if ! cwebp -quiet -z 9 -mt -alpha_filter best "$1" -o "$2"; then
perr "Passing '$1' through 'cwebp' failed!\n"; fi
}
run_oxi_compare() {
if ! oxipng --opt max --strip all --alpha "$1" --out "$2" > /dev/null 2>&1; then
perr "Passing '$1' through 'oxipng' failed!\n"; fi
}
run_oxi_nc() {
if ! oxipng --opt max --strip all --alpha --nc "$1" --out "${tmp}/${output_filename}${2}-nc.png" > /dev/null 2>&1; then
perr "Passing '$1' through 'oxipng --nc' failed!\n"; fi
}
run_oxi_zf() {
if ! oxipng --opt max --strip all --alpha --zopfli "$1" --out "${tmp}/${output_filename}${2}-zf.png" > /dev/null 2>&1; then
perr "Passing '$1' through 'oxipng --zopfli' failed!\n"; fi
}
run_oxi_nc_zf() {
if ! oxipng --opt max --strip all --alpha --nc --zopfli "$1" --out "${tmp}/${output_filename}${2}-nc-zf.png" > /dev/null 2>&1; then
perr "Passing '$1' through 'oxipng --nc --zopfli' failed!\n"; fi
}
oxi_variants() {
# Run the OxiPNG variants on the input, and use the second argument for
# the filename differentiator (e.g. 'output{,-quant}-nc-zf.png').
run_oxi_nc "$1" "$2" &
run_oxi_zf "$1" "$2" &
run_oxi_nc_zf "$1" "$2" &
wait
}
run_quant() {
# Run the OxiPNG variants on the PNGQuant output if it's created.
if pngquant --quality 100-100 --speed 1 --strip "$1" --output "${tmp}/${output_filename}-quant.png"; then
oxi_variants "${tmp}/${output_filename}-quant.png" "-quant"; fi
}
run_oxi_variants() {
if ! cp "$1" "$2"; then
pquit "Failed to copy '$1' to '$2'!\n"; fi
# Run the OxiPNG variants on the input copy and the PNGQuant output.
oxi_variants "$2" &
run_quant "$2" &
wait
}
input_is_png() {
tmp_oxi_baseline="${tmp}/${output_filename}-oxi.png"
tmp_cwebp_output="${tmp}/${output_filename}-cwebp.webp"
# Refuse the input if it's APNG, since some of the tools used in this
# script strip away the animation frames.
if grep "acTL" "$input" > /dev/null 2>&1; then
pquit "'$input' contains an Animation Control Chunk (acTL), refusing operation on APNG image.\n"; fi
run_cwebp "$input" "$tmp_cwebp_output" &
run_oxi_compare "$input" "$tmp_oxi_baseline" &
wait
# Skip the OxiPNG variants if the CWebP output is more than 10% smaller
# than the baseline OxiPNG output (unless 'SISYPHYS_OXI_VAR' is set).
if [ "$(( ( "$(find_size "$tmp_oxi_baseline")" * 9 ) / 10 ))" -le "$(find_size "$tmp_cwebp_output")" ] || [ "$SISYPHUS_OXI_VAR" = 1 ] && [ "$SISYPHUS_OXI_VAR" != 0 ]; then
run_oxi_variants "$input" "${tmp}/${output_filename}-input_copy.png"; fi
}
# In case of GIF
togif() {
tmp_gifsicle_output="${tmp}/${output_filename}-gifsicle-level_${1}.gif"
if ! gifsicle --optimize="$1" --optimize=keep-empty "$input" -o "$tmp_gifsicle_output"; then
pquit "'gifsicle --optimize=\"${1}\"' failed!\n"; fi
}
toapng() {
# GIF2APNG can't handle absolute paths, so we convert them to relative paths.
# Source: https://sourceforge.net/p/gif2apng/discussion/1022150/thread/8ec5e7e288
tmp_apng_input="$(realpath --relative-to="$PWD" "$input")"
tmp_apng_output="$(realpath --relative-to="$PWD" "${tmp}/${output_filename}.apng")"
if ! gif2apng -z2 -i10 "$tmp_apng_input" "$tmp_apng_output" > /dev/null 2>&1; then
pquit "'gif2apng' failed!\n"; fi
if ! exiftool -overwrite_original_in_place -all= "$tmp_apng_output" > /dev/null 2>&1; then
perr "Failed to remove EXIF metadata from '$tmp_apng_output'!\n"; fi
}
towebp() {
tmp_webp_output="${tmp}/${output_filename}-webp.webp"
# Skip WebP output if '$SISYPHUS_NO_WEBP' is set to 1.
if [ "$SISYPHUS_NO_WEBP" = 1 ]; then exit 0; fi
if ! gif2webp -quiet -mt -min_size -m 6 -metadata none "$input" -o "$tmp_webp_output"; then
pquit "'gif2webp' failed!\n"; fi
}
input_is_gif() {
opt_level="1"
while [ "$opt_level" -le "3" ]; do
togif "$opt_level" &
opt_level="$(( "$opt_level" + 1 ))"
done
toapng &
towebp &
wait
}
# File selection (functions have to be defined first)
case "$mime" in
"image/png") input_is_png;;
"image/gif") input_is_gif;;
*) pquit "Mimetype of '$input' is neither PNG nor GIF!\n";;
esac
for tmp_file in "$tmp"/*; do
sizepathlist="$(find_size "$tmp_file")\t${tmp_file}\n$sizepathlist"; done
# 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 the '$tmp' value contains
# slash characters.
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"
smallest="$(printf "%b" "$orderedlist" | head -n1 | cut -d "$(printf "\t")" -f 2-)"
smallest_size="$(find_size "$smallest")"
final_output="${given_output}.${smallest##*.}"
if ! mv "$smallest" "$final_output"; then
pquit "Failed to move '$smallest' to '$final_output'!\n"; fi
method="${smallest##*/"$output_filename"-}"
pstat "Final output:\t" "$final_output \033[37m(${method%.*})\033[0;39m"
pstat "Size diff:\t" "$input_size bytes \033[37m->\033[0;39m \033[1m$smallest_size bytes"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment