Last active
December 3, 2025 19:20
-
-
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`.
This file contains hidden or 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
| #!/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