Skip to content

Instantly share code, notes, and snippets.

@Winterhuman
Last active December 3, 2025 19:20
Show Gist options
  • Select an option

  • Save Winterhuman/e65fe54f3e47b0c26b0e6ad980327f0a to your computer and use it in GitHub Desktop.

Select an option

Save Winterhuman/e65fe54f3e47b0c26b0e6ad980327f0a to your computer and use it in GitHub Desktop.
POSIX sh script to create AVIF for a target SSIM score using binary search. Requires `imagemagick` and `cavif`.
#!/usr/bin/env -S unshare --mount --map-root-user /bin/sh
# Licensed under the Zero-Clause BSD terms: https://opensource.org/license/0bsd
## Requires `imagemagick` and `cavif`
## -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 "\a\033[1m\033[31m%b\033[0;39m" "$1" >&2; }
clear_prog() { printf "\033]9;4;0\007"; }
exit_clear() { clear_prog; exit 1; }
nexit() { printf "\a\033]777;notify;AVIFSSIM;%b\033\\" "$1"; exit_clear; }
pquit() { perr "$1"; nexit "$1"; }
pstat() { printf "\033[1m\033[34m%b\033[0;39m\033[1m%b\033[0;39m\n" "$1" "$2"; }
help() {
cat <<"HELP"; exit 0
Usage: avifssim [OPTION]... SRC DEST
Convert an image into an AVIF image for a given SSIM target.
If DEST is omitted, DEST is set to 'SRC.avif'.
Options:
-c, --clear-cache Clear the cached SSIM values for all images.
-f, --force Overwrite existing destination files.
-t, --target <num> The target SSIM score as a decimal number, e.g.
100 = 100%
12.3456 = 12.3456%
0.123456 = 12.3456%
1 = 1%
-h, --help Display this help message, and then exit.
HELP
}
# Argument parsing
TARGET="0.96" CLEAR="" FORCE="" SKIP="" ARG_COUNT="0"
while [ "$ARG_COUNT" -lt "$#" ]; do
if [ -z "$SKIP" ]; then
case "$1" in
-t|--target)
case "${2:-}" in
[0-9]*) TARGET="$2"; shift ;;
*) pquit "No number was given for '-t|--target'!\n" ;;
esac
;;
-c|--clear-cache) CLEAR="1" ;;
-f|--force) FORCE="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 were given!\n"
## Initialise the cache directory, or re-use the existing one
set +f
for cache_path_exist in /tmp/avifssim.*; do
[ -d "$cache_path_exist" ] || continue
## Remove all cache directories if told to do so
if [ -z "$CLEAR" ]; then
cache_path="$cache_path_exist"
else
rm --recursive -- "$cache_path_exist"
fi
done
set -f
cache_path="${cache_path:-"$(mktemp --dry-run --directory -t avifssim.XXXXXX)"}"
mkdir --parents -- "$cache_path"
exec 4<"$cache_path"
cache_path="/proc/self/fd/4"
### 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=3G,nr_inodes=4 avif-ssim /tmp ||
pquit "Failed to overmount '/tmp/'!\n"
### 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")"
## Mimetype detection
mime="$(file --dereference --mime --brief -- "$src")"
mime="${mime%;*}"
## `cavif` only supports JPG & PNG for its input, so convert all other image
## formats to PNG
tmp_src="/tmp/src.png"
if [ "$mime" = "image/png" ] || [ "$mime" = "image/jpeg" ]; then
cp -- "$src" "$tmp_src"
else
magick -- "$src" "$tmp_src"
fi
## Caching
### `magick` has non-deterministic output, so hash the SRC instead
hash="$(sha256sum -- "$src")"
hash="${hash%% *}"
hash_path="$cache_path/$hash.cache"
### DEST
dest_exists() {
if [ -z "$FORCE" ] && [ -e "$1" ]; then
perr "'$1' already exists!\n"
return 1
fi
}
dest="${2:-"$src_real.avif"}"
dest_exists "$dest" || nexit "'$dest' already exists!\n"
### Target
ideal="${TARGET:-"0.96"}"
[ "$(magick -format "%[fx:${ideal}<1]\n" null: info:)" != 1 ] ||
ideal="$(magick -format "%[fx:${ideal#*0}*100]" null: info:)"
pstat "Input: " "$src_real"
pstat "Output: " "$dest"
pstat "Target >= " "$ideal%"
printf "\n"
# Search mode variables & functions
iteration="0"
threads="$(nproc)"
bad_quality="1"
quality="50"
break_quality="100"
upper_quality="$break_quality"
prev_trial="/tmp/trial.avif"
trial="$prev_trial.new"
encode() {
## `heif-enc` has colour reproduction issues
# heif-enc --avif --matrix_coefficients 1 --colour_primaries 1 \
# --transfer_characteristic 1 --full_range_flag 1 \
# --encoder rav1e -p speed=0,threads="$threads" \
# --quality "$3" --output "$1" -- "$2" >/dev/null 2>&1 ||
# pquit "Failed to create '$1' from '$2'!\n"
## `--depth 8` reduces the final size; `--color rgb` increases it
cavif --quiet --overwrite --threads "$threads" --speed 1 --depth 8 \
--quality "$3" --output "$1" -- "$2"
}
# Use binary search to find the lowest quality image which meets or exceeds the
# target SSIM score
while [ "$bad_quality" -le "$(( upper_quality - 1 ))" ]; do
iteration="$(( iteration + 1 ))"
## Use the cached values if their available.
### These variables hold the entire matching line
cache="$(sed -n "/^$quality/{p;q}" "$hash_path" 2>/dev/null)" ||:
upper_cache="$(sed -n "/^$upper_quality/{p;q}" "$hash_path" 2>/dev/null)" ||:
### And these split the lines into their separate values
IFS=" " read -r _ score size <<-CACHE
$cache
CACHE
IFS=" " read -r _ upper_score upper_size <<-UPPER_CACHE
$upper_cache
UPPER_CACHE
if [ -z "$cache" ]; then
## Create the trial and rate it by score (SSIM) and size (bytes)
encode "$trial" "$tmp_src" "$quality"
## `-compare` must proceed `-metric`, otherwise, the specified
## metric will be ignored.
### The order of images does matter, though it depends on the
### images whether the score changes from this
score="$(
magick "$src" "$trial" -metric ssim -compare \
-format "%[fx:(1-%[distortion])*100]" info:
)"
size="$(wc -c <"$trial")"
fi
## Map each combination of criteria to a number from 0-7
meets_target="$(magick -format "%[fx:${score}>=${ideal}]" null: info:)"
beats_prev_score="1"
if [ -n "$upper_size" ]; then
beats_prev_score="$(
magick -format "%[fx:${score}>=${upper_score}]\n" \
null: info:
)"
fi
beats_size="0"
if [ -z "$upper_size" ] || [ "$size" -lt "$upper_size" ]; then
beats_size="1"
fi
sum="$(( meets_target + 2 * beats_prev_score + 4 * beats_size ))"
edgecase="0"
## Handle every combination of criteria
case "$sum" in
7)
pstat "\033[32mTPS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
upper_quality="$quality"
;;
6)
pstat "\033[31mT\033[32mPS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
rm -- "$trial" ||:
bad_quality="$(( quality + 1 ))"
;;
5)
pstat "\033[32mT\033[31mP\033[32mS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
if [ "$size" -gt "$upper_size" ]; then
rm -- "$trial" ||:
bad_quality="$(( quality + 1 ))"
else
upper_quality="$quality"
fi
;;
4)
pstat "\033[31mTP\033[32mS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
rm -- "$trial" ||:
bad_quality="$(( quality + 1 ))"
;;
3)
pstat "\033[32mTP\033[31mS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
if [ "$size" -gt "$upper_size" ]; then
rm -- "$trial" ||:
bad_quality="$(( quality + 1 ))"
else
upper_quality="$quality"
fi
;;
2)
pstat "\033[31mT\033[32mP\033[31mS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes"
rm -- "$trial" ||:
bad_quality="$(( quality + 1 ))"
;;
1)
pstat "\033[32mT\033[31mPS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
rm -- "$trial" ||:
if [ "$(( quality + 1 ))" -eq "$upper_quality" ] ||
[ "$size" -ne "$upper_size" ]; then
bad_quality="$(( quality + 1 ))"
else
edgecase="1"
fi
;;
*)
pstat "\033[31mTPS\033[0;39m: (Q$quality)\t" \
"$score% \t($size bytes)"
rm -- "$trial" ||:
bad_quality="$(( quality + 1 ))"
;;
esac 2>/dev/null
[ ! -e "$trial" ] || mv -- "$trial" "$prev_trial"
## Cache the "Quality-Score-Size" values
[ -n "$cache" ] || cat <<-CACHE >>"$hash_path"
$quality $score $size
CACHE
## Handle any edge-cases & fail-safes
[ "$quality" -lt "$break_quality" ] || break
quality="$((
edgecase == 1
? quality - 1
: (bad_quality + upper_quality) / 2
))"
## Print a progress bar.
### The maximum number of iterations for binary search should be 8
printf "\033]9;4;1;%d\007" "$(( (iteration * 100) / 8 ))"
done
clear_prog
# Create the final output
## If the best trial was found from the cache, create that cached trial here
if [ ! -f "$prev_trial" ] && [ ! -f "$trial" ]; then
[ "$upper_quality" -lt 100 ] || pquit "No trial met the SSIM target.\n"
encode "$prev_trial" "$tmp_src" "$upper_quality"
fi
final_score="$(
magick "$src" "$prev_trial" -metric ssim -compare \
-format "%[fx:(1-%[distortion])*100]" info:
)"
if [ "$(magick -format "%[fx:${final_score}>=${ideal}]" null: info:)" = 0 ] ||
[ ! -f "$prev_trial" ] && [ ! -f "$trial" ]; then
pquit "No trial met the SSIM target.\n"; fi
final_size="$(wc -c <"$prev_trial")"
pstat "\nOutput stats\033[0;39m: (Q$upper_quality) " \
"$final_score% ($final_size bytes)"
dest_exists "$dest" || nexit "'$dest' already exists!\n"
cp -- "$prev_trial" "$dest"
## Alert the terminal (`\a`), and create a notification (`]777;notify`)
printf "\a\033]777;notify;AVIFSSIM;'%s' is finished! Score: (Q%d) %s. Size: %d bytes.\033\\" \
"$dest" "$upper_quality" "$final_score%" "$final_size"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment