Skip to content

Instantly share code, notes, and snippets.

@pmarreck
Created March 3, 2025 18:09
Show Gist options
  • Save pmarreck/86ad8f31e2b2d1322effaa539f715a2d to your computer and use it in GitHub Desktop.
Save pmarreck/86ad8f31e2b2d1322effaa539f715a2d to your computer and use it in GitHub Desktop.
jpegxl: a bash function to make conversions to/from jpegxl easier!
#!/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