Last active
October 23, 2023 22:28
-
-
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 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
#!/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