Last active
October 1, 2024 11:38
-
-
Save theandrewbailey/4e05e20a229ef2f2c1f9a6d0e326ec2a to your computer and use it in GitHub Desktop.
avifify.sh - Encode PNGs into web optimized AVIF images
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/bash | |
set -e | |
readonly b="\033[1m" # bold text | |
readonly i="\033[3m" # italic text | |
readonly n="\033[0m" # normal text | |
readonly r="\033[0;31m" # red text | |
readonly u="\033[4m" # underlined text | |
readonly regexWidthSteps="×[1234567890¼½¾]+\.png$" | |
readonly niceVal=19 | |
function errorAndExit(){ # message | |
echo -e "$r$1$n" >&2 | |
exit $2 | |
} | |
function validateIntegerOrExit(){ # value, name | |
if [[ ! $1 =~ ^[1234567890]+$ ]]; then | |
errorAndExit "Expected integer for $2 but got $1" 64 | |
fi | |
} | |
function initializeVars(){ | |
forwardArgs="" | |
declare -i -g chroma=444 | |
declare -i -g qualityMax=65 | |
declare -i -g initialQuality=45 | |
declare -i -g qualityMin=25 | |
declare -i -g stepQuality=10 | |
declare -i -g budgetMax=999999 | |
declare -i -g budgetMin=1 | |
declare -i -g squareRootMultiplier=80 | |
squareRootDivisor=2 | |
verbose=False | |
dryrun=False | |
format="avif" | |
declare -i -g baseImageWidth=960 | |
declare -a -g multipliers=("½" "1" "2") | |
customMults=False | |
} | |
initializeVars | |
while getopts 'hvnERf:p:i:r:d:q:c:m:w:x:y:z:' opt; do case "$opt" in | |
h) | |
initializeVars # reset defaults to clear any argument changes | |
columns=$(tput cols) | |
echo -e "${b}NAME$n | |
avifify.sh - Encode PNGs into web optimized AVIF images. | |
${b}SYNOPSIS$n | |
avifify.sh [options...] | |
${b}DESCRIPTION$n | |
Encode all PNGs in the current directory to a series of lossy images of different resolutions suitable for webpages. This script resizes images, and for each resized image: calculate a file size budget, encode the image, and if the encoded image doesn't fall in budget, change quality settings and re-encode until within budget. Resized images and files encoded from them will have '×' in the filename. This script will not delete original PNGs nor intermediate files. | |
For every lossy format, the simplest proxy for quality (among files of that same format) is file size. For images, you need to take the image's dimensions into account with file size. This produces a measurement of bits per pixel (bpp). Smaller images need more bpp to look good. Larger images can get away with fewer bpp and still look acceptable, particularly when using >100 PPI displays. I noticed that the same image at different resolutions at relatively similar quality, the image's file size increases approximately with square root of resolution. This script calculates a maximum budget as square root(image's height × width) × $squareRootMultiplier. Minimum budget is maximum budget divided by $squareRootDivisor. | |
${b}OPTIONS$n | |
$b-h$n | |
Show this and exit. | |
$b-v$n | |
Verbose mode. Will display image resize operations, image dimensions, budget, encoding commands, file sizes, and quality changes. | |
$b-n$n | |
Dry run. Don't actually do anything. Will set $i-v$n and $i-p 1$n. Will print existing resized and encoded files if available. | |
$b-E$n | |
Force encode of the given image(s) without resizing. Mutually exclusive with $b-R$n. | |
$b-R$n | |
Resize image, don't encode anything. Mutually exclusive with $b-E$n. | |
$b-f$n ${i}avif$n or ${i}jpg$n or ${i}jxl$n or ${i}webp$n | |
Format to encode images. Must be one of: avif, jpg, jxl, webp. Please note that this script's default parameters have been tuned for optimal AVIF operation. Changing formats will not automatically change those parameters, and because different formats have different efficiencies, additional parameters must be provided for optimal results. See ${b}EXAMPLES$n section for suggested parameters. Default $format | |
Note that this script doesn't pass quality parameters to webp. Instead, the maximum and minimum budgets are averaged, and passed as the $b-size$n parameter to ${b}cwebp$n. The quality number will remain in intermediate file names. | |
$b-p$n ${i}integer$n | |
Encode up to this many files at one time. Encoders are called with nice -n $niceVal. Defaults to number reported by nproc. | |
$b-i$n ${i}file$n | |
Resize and/or encode this specific file instead of the entire directory. | |
$b-r$n ${i}integer$n | |
Multiply this by square root of dimensions to set maximum (and, indirectly, minimum) budget for an image. Lower means smaller file sizes. 80 limits 720p to 76,800 bytes, 1080p to 115,200 bytes, and 4k to 230,400 bytes. Default $squareRootMultiplier | |
$b-d$n ${i}number$n | |
Divides maximum budget by this to set minimum budget. Should be more than 1. Increasing helps prevent higher quality re-encodes. Lowering causes images to be re-encoded at higher qualities to meet budget, however, this might cause maximum budget to be overshot, so consider lowering $b-z$n ${i}step quality$n, too. Default $squareRootDivisor | |
$b-q$n ${i}integer$n | |
Initial quality for first encode. This value is passed directly to the underlying encoder. Default $initialQuality | |
$b-c$n ${i}420$n or ${i}444$n | |
Initial chroma subsampling setting. Will switch to 444 if re-encoding at higher quality, and will switch to 420 if re-encoding at lower quality. Default $chroma | |
$b-m$n ${i}string$n | |
Set multiplier that controls amount and width of resized images. Calculations use $b-w$n base image width. In addition to integers, also supports fractions ¼, ½, and ¾. Resized images will have ×${i}multiple$n in filenames. Images will not be enlarged, and larger multiples will be skipped if a previous multiple is equal to or larger than the image's original width. Setting one $b-m$n switch will clear all default multiples, so switch must be repeated for additional multiples, see ${b}EXAMPLES$n section. Default multiples: ${multipliers[*]} | |
$b-w$n ${i}integer$n | |
Base image width in pixels for ×1 images. Default $baseImageWidth | |
$b-x$n ${i}integer$n | |
Maximum quality to encode. Will not re-encode even if below budget. Default $qualityMax | |
$b-y$n ${i}integer$n | |
Minimum quality to encode. Will not re-encode even if above budget. Default $qualityMin | |
$b-z$n ${i}integer$n | |
Step quality up or down by this much when re-encoding. Setting this too low will result in a lot of unneeded files and wasted time. Setting this too high may result in files that don't fall within budget. Default $stepQuality | |
${b}EXIT STATUS$n | |
0 if everything happened as expected according to defaults and arguments | |
2 if a PNG was not found for encoding or resizing | |
64 if an invalid argument was passed | |
127 if an external command was not found | |
If the encoder failed (${b}avifenc$n, ${b}cjpeg$n, ${b}cjxl$n, ${b}cwebp$n), this script will pass that status code. | |
${b}ENVIRONMENT$n | |
This script looks for PNGs in the current directory to encode. Those PNGs will not be deleted. | |
This script was written and tested on Debian 12, so similar contemporary Linux installations with bash can be expected to run this. Also needs ${b}bc$n and ${b}convert$n (ImageMagick), and at least one of the following image encoders: ${b}avifenc$n (to encode AVIF), ${b}cjpeg$n (to encode JPEG), ${b}cjxl$n (to encode JPEG-XL), ${b}cwebp$n (to encode WEBP) | |
See libavif repository for AVIF: https://github.com/AOMediaCodec/libavif | |
See mozjpeg repository for JPEG: https://github.com/mozilla/mozjpeg | |
See libjxl repository for JPEG-XL: https://github.com/libjxl/libjxl | |
See libwebp repository for WEBP: https://github.com/webmproject/libwebp | |
${b}EXAMPLES$n | |
There is at least one PNG in the current directory. Encode all to AVIF, logging the details: | |
${b}avifify.sh -v$n | |
Encode the best PNG, but not others, and don't resize it: | |
${b}avifify.sh -E -i theBest.png$n | |
Resize images even smaller and with finer step resizing (will require you to delete PNGs with × in the filename if they already exist): | |
${b}avifify.sh -w 480 -m ¼ -m ½ -m ¾ -m 1 -m 1½ -m 2 -m 2½ -m 3 -m 4$n | |
Encode images at higher quality (higher budget, higher minimum budget, higher starting quality, higher maximum quality): | |
${b}avifify.sh -r 150 -d 1.5 -q 65 -x 85$n | |
Encode to JPEG-XL with reasonable defaults (JXL is less efficient than AVIF at small sizes): | |
${b}avifify.sh -f jxl -r 90 -q 55 -x 85$n | |
Encode to WEBP with reasonable defaults (WEBP is less efficient than AVIF or JXL): | |
${b}avifify.sh -f webp -r 110$n | |
Encode to JPEG with less reasonable defaults (JPEG is way less efficient than others): | |
${b}avifify.sh -f jpg -r 112 -q 80 -x 80 -z 4$n | |
${b}CAVEATS$n | |
Does not clean up files. If script sees that an image already exists, the image won't be overwritten, and assumes it created the image with current parameters. Can be a bonus if you'd like alternative image qualities to choose from. | |
When trying to encode a file ending with (for example) '×3.png', but 3 is not specified as a multiple, the file will not be converted. Try using $b-E$n or $b-m 3$n in that case. | |
Only supports PNGs as original files to encode. | |
"|fmt -w $columns | |
exit 0 | |
;; v) | |
verbose=True | |
forwardArgs+="-v " | |
;; n) | |
dryrun=True | |
verbose=True | |
forwardArgs+="-n -v " | |
;; E) | |
unset multipliers | |
forwardArgs+="-E " | |
;; R) | |
unset format | |
forwardArgs+="-R " | |
;; f) | |
# lowercase format | |
format="${OPTARG,,}" | |
forwardArgs+="-f ${OPTARG,,} " | |
;; p) | |
validateIntegerOrExit "$OPTARG" "-p" | |
processes="$OPTARG" | |
forwardArgs+="-p $OPTARG " | |
;; i) | |
input="$OPTARG" | |
;; r) | |
validateIntegerOrExit "$OPTARG" "-r" | |
squareRootMultiplier="$OPTARG" | |
forwardArgs+="-r $OPTARG " | |
;; d) | |
squareRootDivisor="$OPTARG" | |
forwardArgs+="-d $OPTARG " | |
;; q) | |
validateIntegerOrExit "$OPTARG" "-q" | |
initialQuality="$OPTARG" | |
forwardArgs+="-q $OPTARG " | |
;; c) | |
validateIntegerOrExit "$OPTARG" "-c" | |
if [[ $OPTARG -ne 444 ]] && [[ $OPTARG -ne 420 ]]; then | |
errorAndExit "Bad parameter for -c. Must be either 444 or 420. Exiting." 64 | |
fi | |
chroma="$OPTARG" | |
forwardArgs+="-c $OPTARG " | |
;; m) | |
if [ $customMults = False ]; then | |
multipliers=() | |
customMults=True | |
fi | |
multipliers+=("$OPTARG") | |
forwardArgs+="-m $OPTARG " | |
;; w) | |
validateIntegerOrExit "$OPTARG" "-w" | |
baseImageWidth="$OPTARG" | |
forwardArgs+="-w $OPTARG " | |
;; x) | |
validateIntegerOrExit "$OPTARG" "-x" | |
qualityMax="$OPTARG" | |
forwardArgs+="-x $OPTARG " | |
;; y) | |
validateIntegerOrExit "$OPTARG" "-y" | |
qualityMin="$OPTARG" | |
forwardArgs+="-y $OPTARG " | |
;; z) | |
validateIntegerOrExit "$OPTARG" "-z" | |
stepQuality="$OPTARG" | |
forwardArgs+="-z $OPTARG " | |
;; \?) | |
errorAndExit "Bad option: $opt" 64 | |
;; esac done | |
function checkArgsEnv(){ | |
if [[ $qualityMin -gt $qualityMax ]]; then | |
errorAndExit "Minimum quality less than maximum quality. Exiting." 64 | |
fi | |
if [[ $qualityMin -gt $initialQuality ]]; then | |
errorAndExit "Minimum quality greater than initial quality. Exiting." 64 | |
fi | |
if [[ $initialQuality -gt $qualityMax ]]; then | |
errorAndExit "Initial quality greater than maximum quality. Exiting." 64 | |
fi | |
for mult in "${multipliers[@]}"; do | |
if [[ ! $mult =~ ^[1234567890]*[¼½¾]?$ ]]; then | |
errorAndExit "Bad multiplier: $mult, exiting" 64 | |
fi | |
done | |
if [[ -z $(type -P bc) ]]; then | |
errorAndExit "Can't find bc, install or compile bc" 127 | |
fi | |
if [[ -z $(type -P grep) ]]; then | |
errorAndExit "Can't find grep, what's wrong with you?" 127 | |
fi | |
if [[ -z $(type -P convert) ]]; then | |
errorAndExit "Can't find convert, install or compile ImageMagick" 127 | |
fi | |
if [[ -z $(type -P cp) ]]; then | |
errorAndExit "Can't find cp, what's wrong with you?" 127 | |
fi | |
if [[ -z $(type -P nproc) ]]; then | |
errorAndExit "Can't find nproc, check your coreutils package" 127 | |
fi | |
if [[ -z $(type -P find) ]]; then | |
errorAndExit "Can't find find, are you blind?" 127 | |
fi | |
if [[ -z $(type -P xargs) ]]; then | |
errorAndExit "Can't find xargs, can you parallel?" 127 | |
fi | |
if [[ -n "$format" ]]; then case "$format" in | |
avif) | |
if [[ -z $(type -P avifenc) ]]; then | |
errorAndExit "Can't find avifenc, install or compile avifenc https://github.com/AOMediaCodec/libavif" 127 | |
fi | |
;; jpg) | |
if [[ -z $(type -P cjpeg) ]]; then | |
errorAndExit "Can't find cjpeg, install or compile a jpeg encoder like mozjpeg https://github.com/mozilla/mozjpeg" 127 | |
fi | |
;; jxl) | |
if [[ -z $(type -P cjxl) ]]; then | |
errorAndExit "Can't find cjxl, install or compile jpeg-xl https://github.com/libjxl/libjxl" 127 | |
fi | |
;; webp) | |
if [[ -z $(type -P cwebp) ]]; then | |
errorAndExit "Can't find cwebp, install or compile webp https://github.com/webmproject/libwebp" 127 | |
fi | |
;; *) | |
errorAndExit "Unsupported format: $format, -f must be one of: avif, jxl, jpg, webp" 127 | |
;; esac | |
fi | |
if [[ ${#multipliers[*]} -eq 0 ]] && [[ -z "$format" ]]; then | |
errorAndExit "Invalid parameters: $b-E$r and $b-R$r are mutually exclusive. Exiting." 64 | |
fi | |
} | |
checkArgsEnv | |
function encode(){ # input×1.png, output×1.45.avif, quality, chroma | |
if [[ ! -f $1 ]]; then | |
errorAndExit "No file: $1" 2 | |
fi | |
case "$format" in | |
avif) | |
if [[ ! -f "${encodedFilename}" ]]; then | |
if [ $verbose = True ] ; then | |
echo "running: avifenc -a sharpness=2 -a color:enable-chroma-deltaq=1 -a color:enable-qm=1 -a color:deltaq-mode=3 -a tune=ssim -j 1 -s 3 -y $4 -q $3 $1 $2" | |
fi | |
if [ $dryrun = False ] ; then | |
nice -n $niceVal avifenc -a sharpness=2 -a color:enable-chroma-deltaq=1 -a color:enable-qm=1 -a color:deltaq-mode=3 -a tune=ssim -j 1 -s 3 -y $4 -q $3 "$1" "$2" >/dev/null | |
if [[ $? != 0 ]]; then | |
errorAndExit "avifenc with $encodedFilename terminated unexpectedly with code $?" $? | |
fi | |
fi | |
fi | |
;; jpg) | |
if [[ ! -f "${encodedFilename}" ]]; then | |
case "$4" in | |
444) | |
local -r chromaJpg="1x1" | |
;; 420) | |
local -r chromaJpg="2x2" | |
;; esac | |
if [ $verbose = True ] ; then | |
echo "running: cjpeg -sample $chromaJpg -quality $3 -outfile $2 $1" | |
fi | |
if [ $dryrun = False ] ; then | |
nice -n $niceVal cjpeg -sample $chromaJpg -quality $3 -outfile "$2" "$1" | |
if [[ $? != 0 ]]; then | |
errorAndExit "cjpeg with $encodedFilename terminated unexpectedly with code $?" $? | |
fi | |
fi | |
fi | |
;; jxl) | |
if [[ ! -f "${encodedFilename}" ]]; then | |
case "$4" in | |
444) | |
local -r chromaJxl="1" | |
;; 420) | |
local -r chromaJxl="2" | |
;; esac | |
if [ $verbose = True ] ; then | |
echo "running: cjxl $1 $2 -q $3 -e 10 --num_threads=0 --resampling=$chromaJxl --quiet" | |
fi | |
if [ $dryrun = False ] ; then | |
nice -n $niceVal cjxl "$1" "$2" -q $3 -e 10 --num_threads=0 --resampling=$chromaJxl --quiet >&2 >/dev/null | |
if [[ $? != 0 ]]; then | |
errorAndExit "cjxl with $encodedFilename terminated unexpectedly with code $?" $? | |
fi | |
fi | |
fi | |
;; webp) | |
if [[ ! -f "${encodedFilename}" ]]; then | |
if [ $verbose = True ] ; then | |
echo "running: cwebp -quiet -sharp_yuv -m 6 -size $(((budgetMax+budgetMin)/2)) $1 -o $2" | |
fi | |
if [ $dryrun = False ] ; then | |
nice -n $niceVal cwebp -quiet -sharp_yuv -m 6 -size $(((budgetMax+budgetMin)/2)) "$1" -o "$2" | |
if [[ $? != 0 ]]; then | |
errorAndExit "cwebp with $encodedFilename terminated unexpectedly with code $?" $? | |
fi | |
fi | |
fi | |
;; esac | |
} | |
function calculateBudget(){ # input×1.png | |
if [[ ! -f $1 ]]; then | |
errorAndExit "No file: $1" 2 | |
fi | |
local -r dimensions=$(file $1|grep -Eo "[[:digit:]]+ *x *[[:digit:]]+") | |
local -r width=${dimensions%x*} | |
local -r height=${dimensions##*x} | |
if [[ -n $width ]] && [[ -n $height ]]; then | |
budgetMax=$(echo "$squareRootMultiplier*sqrt($width*$height)/1" | bc) | |
budgetMin=$(echo "$budgetMax/$squareRootDivisor/1" | bc) | |
fi | |
if [[ $budgetMin -gt $budgetMax ]]; then | |
errorAndExit "$1 minimum budget ($budgetMin) greater than maximum budget ($budgetMax). Did you set the divisor incorrectly? Exiting." 64 | |
fi | |
if [ $verbose = True ] ; then | |
echo "$1 ($dimensions) budget: $budgetMin-$budgetMax bytes" | |
fi | |
} | |
function encodeToBudget(){ # input×1.png | |
if [[ ! -f $1 ]]; then | |
errorAndExit "No file: $1" 2 | |
fi | |
# only encode PNGs that match multiples | |
if [[ ${#multipliers[*]} -gt 0 ]]; then | |
local match=False | |
if [[ "$1" =~ $regexWidthSteps ]]; then | |
for mult in "${multipliers[@]}"; do | |
if [[ $1 == *×$mult.png ]]; then | |
match=True | |
break | |
fi | |
done | |
fi | |
if [ $match = False ] ; then | |
return | |
fi | |
fi | |
calculateBudget "$1" | |
local -i quality=$initialQuality | |
while [[ true ]]; do | |
local encodedFilename="${1%.*}.$quality.$format" | |
encode "$1" "$encodedFilename" $quality $chroma | |
if [[ -f "${encodedFilename}" ]]; then | |
size=$(wc -c < "$encodedFilename") | |
if [[ $size -gt $budgetMax ]] && [[ $quality -le $initialQuality ]] && [[ $((quality-stepQuality)) -ge $qualityMin ]] && [[ $quality -gt $qualityMin ]]; then | |
quality=$((quality-stepQuality)) | |
chroma=420 | |
if [ $verbose = True ] ; then | |
echo "encoded $encodedFilename, size: $size, lowering quality to $quality chroma 4:2:0" | |
fi | |
elif [[ $size -lt $budgetMin ]] && [[ $quality -ge $initialQuality ]] && [[ $((quality+stepQuality)) -le $qualityMax ]] && [[ $quality -lt $qualityMax ]]; then | |
quality=$((quality+stepQuality)) | |
chroma=444 | |
if [ $verbose = True ] ; then | |
echo "encoded $encodedFilename, size: $size, increasing quality to $quality chroma 4:4:4" | |
fi | |
else | |
final="${1%.*}.$format" | |
if [ $dryrun = False ] ; then | |
cp --reflink=auto "$encodedFilename" "$final" | |
fi | |
if [ $verbose = True ] ; then | |
echo "encoded $encodedFilename, size: $size, copied to $final" | |
fi | |
break | |
fi | |
else | |
break | |
fi | |
done | |
} | |
function resize(){ # input.png | |
if [[ "$1" =~ $regexWidthSteps ]]; then | |
return | |
fi | |
if [[ ! -f $1 ]]; then | |
errorAndExit "No file: $1" 2 | |
fi | |
local -r dimensions=$(file $1|grep -Eo "[[:digit:]]+ *x *[[:digit:]]+") | |
local -r width=${dimensions%x*} | |
for mult in "${multipliers[@]}"; do | |
local -i newWidth=0 | |
local multlen=${#mult} | |
for ((index=0;index<multlen;index++)); do | |
local d="${mult:index:1}" | |
case $d in | |
"¼") | |
newWidth=$((newWidth + baseImageWidth/4)) | |
;; "½") | |
newWidth=$((newWidth + baseImageWidth/2)) | |
;; "¾") | |
newWidth=$((newWidth + baseImageWidth*3/4)) | |
;; *) | |
newWidth=$((newWidth*10 + baseImageWidth*d)) | |
;; esac | |
done | |
local resizedFilename="${1%.*}×$mult.png" | |
if [[ ! -f "${resizedFilename}" ]]; then | |
if [[ $newWidth -ge $width ]]; then | |
# original resolution reached, so copy instead of resize | |
if [ $verbose = True ] ; then | |
echo "running: cp --reflink=auto $1 $resizedFilename" | |
fi | |
if [ $dryrun = False ] ; then | |
cp --reflink=auto "$1" "$resizedFilename" & | |
fi | |
break | |
fi | |
if [ $verbose = True ] ; then | |
echo "running: convert $1 -depth 16 -gamma 0.454545 -filter lanczos -resize ${newWidth}x -gamma 2.2 -depth 8 $resizedFilename" | |
fi | |
if [ $dryrun = False ] ; then | |
# don't forget to gamma correct resize, see http://www.ericbrasseur.org/gamma.html | |
nice -n $niceVal convert "$1" -depth 16 -gamma 0.454545 -filter lanczos -resize ${newWidth}x -gamma 2.2 -depth 8 "$resizedFilename" & | |
fi | |
fi | |
done | |
wait | |
} | |
function countCPUs(){ | |
if [ $dryrun = True ] ; then | |
processes=1 | |
elif [[ -z "$processes" ]]; then | |
processes=$(nproc) | |
fi | |
} | |
if [[ -z "$input" ]]; then | |
# no input supplied, loop over PNGs in directory | |
countCPUs | |
# PNGs with larger filesize generally have larger dimensions and complexity, so process them first | |
if [[ ${#multipliers[*]} -eq 0 ]]; then | |
# multipliers not present (e.g. -E passed), so encode directly, don't resize | |
ls -S -1 | grep -E ".*\.png" | xargs -P $processes -I @ $0 $forwardArgs -i @ | |
else | |
readonly nextArgs="$forwardArgs -R " | |
ls -S -1 | grep -E ".*\.png" | xargs -P $processes -I @ $0 $nextArgs -i @ | |
ls -S -1 | grep -E $regexWidthSteps | xargs -P $processes -I @ $0 $forwardArgs -i @ | |
fi | |
elif [[ -n "$format" ]]; then | |
# format parameter present, so convert single PNG | |
encodeToBudget "$input" | |
elif [[ ${#multipliers[*]} -gt 0 ]]; then | |
# format parameter not present (e.g. -R passed), multipliers present, so resize PNG | |
resize $input | |
fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Run with
-h
to show documentation.