Skip to content

Instantly share code, notes, and snippets.

@theandrewbailey
Last active October 1, 2024 11:38
Show Gist options
  • Save theandrewbailey/4e05e20a229ef2f2c1f9a6d0e326ec2a to your computer and use it in GitHub Desktop.
Save theandrewbailey/4e05e20a229ef2f2c1f9a6d0e326ec2a to your computer and use it in GitHub Desktop.
avifify.sh - Encode PNGs into web optimized AVIF images
#! /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
@theandrewbailey
Copy link
Author

Run with -h to show documentation.

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