Created
March 3, 2025 18:09
-
-
Save pmarreck/86ad8f31e2b2d1322effaa539f715a2d to your computer and use it in GitHub Desktop.
jpegxl: a bash function to make conversions to/from jpegxl easier!
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 bash | |
### jpegxl: a bash function to make conversions to/from jpegxl easier! | |
# Silence function - runs a command silently but preserves exit code | |
silence() { | |
"$@" >/dev/null 2>&1 | |
return $? | |
} | |
# Check for required dependencies | |
check_dependencies() { | |
local missing_deps=() | |
# Check for JPEG XL tools | |
if ! silence command -v cjxl; then | |
missing_deps+=("cjxl") | |
fi | |
if ! silence command -v djxl; then | |
missing_deps+=("djxl") | |
fi | |
# Check for ImageMagick | |
if ! silence command -v magick; then | |
missing_deps+=("ImageMagick") | |
fi | |
# Determine which stat command to use | |
if silence command -v gstat; then | |
# Use GNU stat from coreutils if available (macOS with homebrew/nix) | |
STAT="gstat" | |
elif silence stat --version; then | |
# Use GNU stat if available (Linux) | |
STAT="stat" | |
else | |
# No GNU stat available | |
missing_deps+=("GNU stat (gstat)") | |
fi | |
export STAT | |
# Determine which date command to use | |
if silence command -v gdate; then | |
# Use GNU date from coreutils if available (macOS with homebrew/nix) | |
DATE="gdate" | |
elif silence date --version; then | |
# Use GNU date if available (Linux) | |
DATE="date" | |
else | |
# No GNU date available | |
missing_deps+=("GNU date (gdate)") | |
fi | |
export DATE | |
# Determine which numfmt command to use | |
if silence command -v gnumfmt; then | |
# Use GNU numfmt from coreutils if available (macOS with homebrew/nix) | |
NUMFMT="gnumfmt" | |
elif silence numfmt --version; then | |
# Use GNU numfmt if available (Linux) | |
NUMFMT="numfmt" | |
else | |
# No GNU numfmt available, but this is optional | |
NUMFMT="" | |
fi | |
export NUMFMT | |
if [[ ${#missing_deps[@]} -gt 0 ]]; then | |
echo "Error: Missing required dependencies: ${missing_deps[*]}" >&2 | |
if [[ " ${missing_deps[*]} " =~ " GNU stat (gstat) " || " ${missing_deps[*]} " =~ " GNU date (gdate) " ]]; then | |
echo "Note: This script requires GNU versions of stat and date." >&2 | |
echo "On macOS, install them with: brew install coreutils" >&2 | |
echo "Or with Nix/nix-darwin: nix-shell -p coreutils-prefixed (or add to global flake.nix)" >&2 | |
fi | |
return 1 | |
fi | |
return 0 | |
} | |
# Debug output function | |
debug() { | |
[ -n "$DEBUG" ] && echo "DEBUG: $*" >&2 | |
} | |
# Create a temporary file with a unique name | |
create_temp_file() { | |
local extension=$1 | |
local temp_file | |
# Use a more unique pattern with timestamp and random number | |
temp_file=$(mktemp "/tmp/jpegxl.$(date +%s).XXXXXX.$extension") | |
debug "Created temporary file: $temp_file" | |
echo "$temp_file" | |
} | |
print_help() { | |
cat << EOF | |
Usage: jpegxl [OPTIONS] FILE... | |
Convert images to and from JPEG XL format. | |
Options: | |
--help, -h Show this help message and exit | |
--effort=N Set compression effort (1-10, default: 10) | |
--distance=N Set compression distance (0-15, default: 1) | |
0 is lossless, higher values = more lossy | |
--quality=N Set compression quality (1-100, default: 93) | |
Equivalent to setting distance, but in a more familiar scale | |
--lossy Use lossy compression (default: lossless) | |
--no-hdr Disable HDR preservation (default: enabled) | |
--basic Use basic compression settings (effort=5, lossy) | |
--to=FORMAT Convert JXL to another format (jpeg, png, etc.) | |
--preserve-timestamps Preserve original file creation/modification timestamps | |
test Run self-test | |
Environment Variables: | |
JPEGXL_DEFAULT_EFFORT Set default compression effort (1-10, default: 10) | |
JPEGXL_DEFAULT_DISTANCE Set default compression distance (0-15, default: 1) | |
DEBUG Enable debug output | |
Examples: | |
# Convert a JPEG to JXL (lossless by default) | |
jpegxl image.jpg | |
# Convert a JPEG to JXL with lossy compression | |
jpegxl --lossy image.jpg | |
# Convert a JPEG to JXL with specific quality | |
jpegxl --quality=80 image.jpg | |
# Convert a JXL back to JPEG | |
jpegxl --to=jpeg image.jxl | |
# Convert a JXL to PNG | |
jpegxl --to=png image.jxl | |
# Preserve original file timestamps | |
jpegxl --preserve-timestamps image.jpg | |
Notes: | |
- By default, the script preserves as much data as possible (lossless, HDR, metadata) | |
- Use --lossy or --basic to sacrifice quality for compression | |
- Use --no-hdr to disable HDR preservation | |
Dependencies: | |
- JPEG XL tools (cjxl, djxl) | |
- ImageMagick (convert, identify) | |
- ExifTool (optional, for better metadata handling) | |
EOF | |
} | |
run_test() { | |
# Check dependencies | |
check_dependencies || return 1 | |
local test_dir=$(mktemp -d) | |
local test_passed=true | |
echo "Running jpegxl test in temporary directory: $test_dir" | |
# Create test images | |
echo "Creating test images..." | |
# Create a simple PNG test image | |
magick -size 100x100 gradient:blue-red "$test_dir/test.png" | |
# Create a PNG with alpha channel | |
magick -size 100x100 xc:none -fill red -draw "circle 50,50 20,30" "$test_dir/alpha_test.png" | |
# Create a simple JPG test image | |
magick -size 100x100 gradient:green-yellow "$test_dir/test.jpg" | |
# Add some EXIF metadata to the JPEG | |
magick "$test_dir/test.jpg" -set exif:BrightnessValue "2.5" "$test_dir/exif_test.jpg" | |
# Create a simple WebP test image | |
magick -size 100x100 gradient:red-green "$test_dir/test.webp" | |
# Test conversion | |
echo "Testing conversion..." | |
( | |
cd "$test_dir" | |
# Basic conversion tests | |
silence jpegxl test.png | |
silence jpegxl test.jpg | |
silence jpegxl test.webp | |
# Alpha channel preservation test | |
silence jpegxl alpha_test.png | |
# Metadata preservation test | |
silence jpegxl exif_test.jpg | |
# Verify files were created | |
if [[ ! -f "test.jxl" ]]; then | |
echo "ERROR: Failed to convert PNG to JXL" | |
test_passed=false | |
fi | |
if [[ ! -f "test.jxl" ]]; then | |
echo "ERROR: Failed to convert JPG to JXL" | |
test_passed=false | |
fi | |
# Test conversion back from JXL | |
jpegxl --to=jpeg test.jxl | |
if [[ ! -f "test.jpeg" ]]; then | |
echo "ERROR: Failed to convert JXL to JPEG" | |
test_passed=false | |
fi | |
jpegxl --to=png test.jxl | |
if [[ ! -f "test.png" ]]; then | |
echo "ERROR: Failed to convert JXL to PNG" | |
test_passed=false | |
fi | |
# Test alpha channel preservation | |
if [[ -f "alpha_test.jxl" ]]; then | |
# First, rename the original PNG to avoid overwriting | |
mv alpha_test.png alpha_test_original.png | |
# Convert JXL back to PNG | |
silence jpegxl --to=png alpha_test.jxl | |
# Check if alpha channel is preserved using ImageMagick | |
alpha_original=$(identify -verbose alpha_test_original.png | grep -i "Alpha" || echo "") | |
alpha_jxl=$(identify -verbose alpha_test.png | grep -i "Alpha" || echo "") | |
if [[ -z "$alpha_jxl" && -n "$alpha_original" ]]; then | |
echo "ERROR: Alpha channel not preserved in JXL conversion" | |
test_passed=false | |
else | |
echo "Alpha channel preserved: OK" | |
fi | |
else | |
echo "ERROR: Failed to convert PNG with alpha to JXL" | |
test_passed=false | |
fi | |
# Test metadata preservation | |
if [[ -f "exif_test.jxl" ]]; then | |
# Convert JXL back to JPEG for metadata testing | |
silence jpegxl --to=jpeg exif_test.jxl | |
# Check if EXIF data is preserved | |
if silence command -v exiftool; then | |
# Use exiftool if available | |
exif_original=$(exiftool exif_test.jpg | grep -i "Camera Model Name" || echo "") | |
exif_jxl=$(exiftool exif_test.jpeg | grep -i "Camera Model Name" || echo "") | |
else | |
# Fall back to ImageMagick | |
exif_original=$(identify -verbose exif_test.jpg | grep -i "BrightnessValue" || echo "") | |
exif_jxl=$(identify -verbose exif_test.jpeg | grep -i "BrightnessValue" || echo "") | |
fi | |
if [[ -z "$exif_jxl" && -n "$exif_original" ]]; then | |
echo "ERROR: EXIF metadata not preserved" | |
test_passed=false | |
else | |
echo "EXIF metadata preserved: OK" | |
fi | |
else | |
echo "ERROR: Failed to convert JPEG with EXIF to JXL" | |
test_passed=false | |
fi | |
# Test timestamp preservation | |
if [[ -f "test.jxl" ]]; then | |
# Get original timestamp (using GNU stat) | |
original_time=$($STAT -c "%Y" test.png) | |
# Set a specific timestamp on the original file | |
touch -t 202001010000 test.png | |
# Get test timestamp | |
test_time=$($STAT -c "%Y" test.png) | |
# Convert with timestamp preservation | |
silence jpegxl --preserve-timestamps test.png | |
# Check if timestamp was preserved | |
jxl_time=$($STAT -c "%Y" test.jxl) | |
if [[ "$jxl_time" != "$test_time" ]]; then | |
echo "ERROR: Timestamp not preserved" | |
test_passed=false | |
else | |
echo "Timestamp preserved: OK" | |
fi | |
# Restore original timestamp (using GNU date) | |
touch -r /dev/null -t "$($DATE -d "@$original_time" "+%Y%m%d%H%M.%S")" test.png | |
else | |
echo "ERROR: Failed to convert PNG to JXL for timestamp test" | |
test_passed=false | |
fi | |
) | |
# Report results | |
if [[ "$test_passed" == "true" ]]; then | |
echo "Test PASSED: Successfully converted test images to and from JXL format" | |
else | |
echo "Test FAILED: Some conversions did not complete successfully" | |
fi | |
# Cleanup | |
echo "Cleaning up test directory..." | |
rm -rf "$test_dir" | |
return $([[ "$test_passed" == "true" ]] && echo 0 || echo 1) | |
} | |
jpegxl() { | |
# DEBUG=true | |
# Check dependencies | |
check_dependencies || return 1 | |
# Default values | |
JPEGXL_DEFAULT_EFFORT=${JPEGXL_DEFAULT_EFFORT:-10} | |
JPEGXL_DEFAULT_DISTANCE=${JPEGXL_DEFAULT_DISTANCE:-1} | |
local to_format="" | |
local quality=95 | |
local lossy=false | |
local preserve_hdr=true # Default to preserving HDR | |
local no_hdr=false | |
local distance=$JPEGXL_DEFAULT_DISTANCE | |
local effort=$JPEGXL_DEFAULT_EFFORT | |
local basic_mode=false | |
local preserve_timestamps=false | |
# Parse arguments | |
local files=() | |
while [[ $# -gt 0 ]]; do | |
case "$1" in | |
--help) | |
print_help | |
return 0 | |
;; | |
test) | |
run_test | |
return $? | |
;; | |
--to=*) | |
to_format="${1#*=}" | |
;; | |
--quality=*) | |
quality="${1#*=}" | |
;; | |
--lossy) | |
lossy=true | |
;; | |
--distance=*) | |
distance="${1#*=}" | |
;; | |
--effort=*) | |
effort="${1#*=}" | |
;; | |
--no-hdr) | |
no_hdr=true | |
preserve_hdr=false | |
;; | |
--basic) | |
basic_mode=true | |
lossy=true | |
effort=5 | |
;; | |
--preserve-timestamps) | |
preserve_timestamps=true | |
;; | |
*) | |
files+=("$1") | |
;; | |
esac | |
shift | |
done | |
debug "Effort set to: $effort" | |
debug "Distance set to: $distance" | |
debug "Lossy mode: $lossy" | |
debug "Preserve HDR: $preserve_hdr" | |
debug "Basic mode: $basic_mode" | |
debug "Preserve timestamps: $preserve_timestamps" | |
if [[ ${#files[@]} -eq 0 ]]; then | |
echo "Error: No input files specified" | |
print_help | |
return 1 | |
fi | |
for file in "${files[@]}"; do | |
if [[ ! -f "$file" ]]; then | |
echo "File not found: $file" | |
continue | |
fi | |
local extension="${file##*.}" | |
extension="${extension,,}" # Convert to lowercase | |
debug "File extension: $extension" | |
if [[ "$extension" == "jxl" && -n "$to_format" ]]; then | |
# Handle JXL to other format conversion | |
local output="${file%.*}.$to_format" | |
debug "Output file: $output" | |
case "$to_format" in | |
jpg|jpeg) | |
debug "Converting to JPEG with quality $quality" | |
if [[ "$output" == "-" ]]; then | |
djxl "$file" - --jpeg_quality="$quality" --pixels_to_jpeg | |
else | |
djxl "$file" "$output" --jpeg_quality="$quality" --pixels_to_jpeg | |
fi | |
;; | |
png) | |
debug "Converting to PNG" | |
if [[ "$output" == "-" ]]; then | |
djxl "$file" - | |
else | |
djxl "$file" "$output" | |
fi | |
;; | |
*) | |
# For other formats, use ImageMagick | |
debug "Using ImageMagick to convert to $to_format" | |
if [[ "$to_format" == "jpeg" || "$to_format" == "jpg" ]]; then | |
magick "$file" -quality "$quality" "$output" | |
else | |
magick "$file" "$output" | |
fi | |
;; | |
esac | |
if [[ "$preserve_timestamps" == "true" ]]; then | |
debug "Preserving timestamps from $file to $output" | |
touch -r "$file" "$output" | |
fi | |
if [[ -f "$output" ]]; then | |
original_size=$($STAT -c "%s" "$file") | |
new_size=$($STAT -c "%s" "$output") | |
ratio=$(awk "BEGIN {printf \"%.2f\", ($new_size / $original_size) * 100}") | |
if [[ -n "$NUMFMT" ]]; then | |
original_size_human=$($NUMFMT --to=iec "$original_size" 2>/dev/null) | |
new_size_human=$($NUMFMT --to=iec "$new_size" 2>/dev/null) | |
echo "$file → $output (${new_size_human}, ${ratio}% of original ${original_size_human})" | |
else | |
# If numfmt is not available, use plain bytes | |
echo "$file → $output (${new_size} bytes, ${ratio}% of original ${original_size} bytes)" | |
fi | |
fi | |
continue | |
fi | |
# Handle conversion to JXL | |
output="${file%.*}.jxl" | |
debug "Output file: $output" | |
# Create a temporary PNG for formats that need conversion | |
local temp_files=() | |
if [[ "$extension" == "webp" || "$extension" == "heic" ]]; then | |
temp_png=$(create_temp_file "png") | |
temp_files+=("$temp_png") | |
debug "Converting $extension to PNG using ImageMagick: $file -> $temp_png" | |
magick "$file" "$temp_png" | |
file="$temp_png" | |
extension="png" | |
debug "Using temporary file: $file" | |
fi | |
# Flag to track if we've already done the conversion | |
local conversion_done=false | |
# Determine conversion settings based on file type and options | |
if [[ "$extension" == "jpg" || "$extension" == "jpeg" ]]; then | |
if [[ "$lossy" == "true" ]]; then | |
# For lossy JPEG conversion with HDR preservation, use the PNG path | |
if [[ "$preserve_hdr" == "true" && "$no_hdr" == "false" ]]; then | |
debug "Using lossy mode with HDR preservation for JPEG" | |
temp_png=$(create_temp_file "png") | |
temp_files+=("$temp_png") | |
debug "Converting to PNG with HDR preservation" | |
magick "$file" -colorspace RGB "$temp_png" | |
debug "cjxl -e $effort -d $distance -x color_space=RGB_D65_SRG_Rel_Lin \"$temp_png\" \"$output\"" | |
cjxl -e $effort -d $distance -x color_space=RGB_D65_SRG_Rel_Lin "$temp_png" "$output" | |
conversion_done=true | |
else | |
# Standard lossy mode without HDR preservation | |
debug "Using lossy mode for JPEG conversion" | |
debug "cjxl -e $effort -d $distance --lossless_jpeg=0 \"$file\" \"$output\"" | |
cjxl -e $effort -d $distance --lossless_jpeg=0 "$file" "$output" | |
conversion_done=true | |
fi | |
else | |
# Default to lossless JPEG transcoding (preserves metadata) | |
debug "Using lossless JPEG transcoding" | |
debug "cjxl -e $effort --lossless_jpeg=1 \"$file\" \"$output\"" | |
cjxl -e $effort --lossless_jpeg=1 "$file" "$output" | |
conversion_done=true | |
fi | |
fi | |
# Only proceed with other formats if we haven't done the conversion yet | |
if [[ "$conversion_done" == "false" ]]; then | |
if [[ "$extension" == "png" || "$extension" == "gif" ]]; then | |
if [[ "$preserve_hdr" == "true" && "$no_hdr" == "false" ]]; then | |
# For PNG/GIF with HDR preservation | |
debug "Using $([ "$lossy" == "true" ] && echo "lossy" || echo "lossless") mode with HDR preservation for PNG/GIF" | |
temp_png=$(create_temp_file "png") | |
temp_files+=("$temp_png") | |
debug "Converting to PNG with HDR preservation" | |
magick "$file" -colorspace RGB "$temp_png" | |
if [[ "$lossy" == "true" ]]; then | |
debug "cjxl -e $effort -d $distance -x color_space=RGB_D65_SRG_Rel_Lin \"$temp_png\" \"$output\"" | |
cjxl -e $effort -d $distance -x color_space=RGB_D65_SRG_Rel_Lin "$temp_png" "$output" | |
else | |
debug "cjxl -e $effort -d 0 -x color_space=RGB_D65_SRG_Rel_Lin \"$temp_png\" \"$output\"" | |
cjxl -e $effort -d 0 -x color_space=RGB_D65_SRG_Rel_Lin "$temp_png" "$output" | |
fi | |
conversion_done=true | |
elif [[ "$lossy" == "true" ]]; then | |
# Use lossy mode for PNG/GIF without HDR preservation | |
debug "Using lossy mode for PNG/GIF conversion" | |
debug "cjxl -e $effort -d $distance \"$file\" \"$output\"" | |
cjxl -e $effort -d $distance "$file" "$output" | |
conversion_done=true | |
else | |
# Default to lossless for PNG/GIF without HDR preservation | |
debug "Using lossless mode for PNG/GIF conversion" | |
debug "cjxl -e $effort -d 0 \"$file\" \"$output\"" | |
cjxl -e $effort -d 0 "$file" "$output" | |
conversion_done=true | |
fi | |
fi | |
# Only proceed with other formats if we still haven't done the conversion | |
if [[ "$conversion_done" == "false" ]]; then | |
# For other formats, use lossless by default | |
if [[ "$preserve_hdr" == "true" && "$no_hdr" == "false" ]]; then | |
# With HDR preservation for other formats | |
debug "Using $([ "$lossy" == "true" ] && echo "lossy" || echo "lossless") mode with HDR preservation for $extension" | |
temp_png=$(create_temp_file "png") | |
temp_files+=("$temp_png") | |
debug "Converting to PNG with HDR preservation" | |
magick "$file" -colorspace RGB "$temp_png" | |
if [[ "$lossy" == "true" ]]; then | |
debug "cjxl -e $effort -d $distance -x color_space=RGB_D65_SRG_Rel_Lin \"$temp_png\" \"$output\"" | |
cjxl -e $effort -d $distance -x color_space=RGB_D65_SRG_Rel_Lin "$temp_png" "$output" | |
else | |
debug "cjxl -e $effort -d 0 -x color_space=RGB_D65_SRG_Rel_Lin \"$temp_png\" \"$output\"" | |
cjxl -e $effort -d 0 -x color_space=RGB_D65_SRG_Rel_Lin "$temp_png" "$output" | |
fi | |
else | |
# Without HDR preservation for other formats | |
debug "Using $([ "$lossy" == "true" ] && echo "lossy" || echo "lossless") mode for $extension conversion" | |
if [[ "$lossy" == "true" ]]; then | |
debug "cjxl -e $effort -d $distance \"$file\" \"$output\"" | |
cjxl -e $effort -d $distance "$file" "$output" | |
else | |
debug "cjxl -e $effort -d 0 \"$file\" \"$output\"" | |
cjxl -e $effort -d 0 "$file" "$output" | |
fi | |
fi | |
fi | |
fi | |
# Preserve timestamps if requested | |
if [[ "$preserve_timestamps" == "true" ]]; then | |
debug "Preserving timestamps from $file to $output" | |
touch -r "$file" "$output" | |
fi | |
# Show compression ratio | |
if [[ -f "$output" ]]; then | |
original_size=$($STAT -c "%s" "$file") | |
new_size=$($STAT -c "%s" "$output") | |
ratio=$(awk "BEGIN {printf \"%.2f\", ($new_size / $original_size) * 100}") | |
if [[ -n "$NUMFMT" ]]; then | |
original_size_human=$($NUMFMT --to=iec "$original_size" 2>/dev/null) | |
new_size_human=$($NUMFMT --to=iec "$new_size" 2>/dev/null) | |
echo "$file → $output (${new_size_human}, ${ratio}% of original ${original_size_human})" | |
else | |
# If numfmt is not available, use plain bytes | |
echo "$file → $output (${new_size} bytes, ${ratio}% of original ${original_size} bytes)" | |
fi | |
fi | |
# Clean up temporary files | |
for temp_file in "${temp_files[@]}"; do | |
if [[ -f "$temp_file" ]]; then | |
debug "Removing temporary file: $temp_file" | |
rm -f "$temp_file" | |
fi | |
done | |
done | |
} | |
# Export the function so it can be used by find -exec | |
export -f jpegxl | |
# Run the function, passing along any args, if this file was run directly (such as via sudo) instead of as an include | |
# Sometimes, $0 contains a leading dash to indicate an interactive (or is it login?) shell, | |
# which is apparently an old convention (which also broke the basename call on OS X) | |
_me=$(basename "${0##\-}") | |
if [ "$_me" = "jpegxl" ]; then | |
$_me "$@" | |
fi | |
unset _me |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment