Skip to content

Instantly share code, notes, and snippets.

@DraconicDragon
Last active January 13, 2026 03:46
Show Gist options
  • Select an option

  • Save DraconicDragon/912be394ef512271d106cd6eabeb61b7 to your computer and use it in GitHub Desktop.

Select an option

Save DraconicDragon/912be394ef512271d106cd6eabeb61b7 to your computer and use it in GitHub Desktop.
KDE Klipper/Clipboard copy event checks -> turns copied JXL files into more-compatible formats (PNG, JPEG, WebP or AVIF); adds seamlessly to clipboard, convenient for sharing online. Created with much help using Gemini 3 Pro Preview. Requirements are found in script comments. ISSUES: Anim & multilayer JXL untested; Metadata likely not respected
#!/bin/bash
# ==============================================================================
# JPEG-XL CLIPBOARD AUTOCONVERT WATCHDOG (KDE/Wayland)
# ==============================================================================
# Description:
# Made for and tested with Arch Linux/KDE/Wayland/Klipper (KDE built-in clipboard)
# Animation, multi layer, etc not tested. Only still images (screenshots, web images, renders) were tested
# Automatically detects copied JXL files and converts them to compatible
# formats (JPEG/PNG/WebP/AVIF) for pasting into apps like Discord.
#
# Dependencies:
# - libjxl (djxl, jxlinfo)
# - imagemagick (magick)
# - xclip (IMPORTANT: used for background clipboard writing to bypass wl-paste focus steal)
# - python3 (used for URI parsing)
# - qdbus & dbus-monitor (standard in KDE)
#
# Usage:
# 1. Make executable: chmod +x jxl-clipboard-watcher-autoconvert.sh
# 2. Run in terminal: ./jxl-clipboard-watcher-autoconvert.sh
# 3. (Optional) Add to Autostart to run on login.
# ==============================================================================
# --- CONFIGURATION ---
# Syntax: "FORMAT;VALUE"
# Formats: JPEG (Quality 0-100), PNG (Compression 0-9), WEBP (Quality 0-100)
# WEBP_LOSSLESS (Effort 0-6), AVIF (Quality 0-100, 100=lossless)
# 1. Target for Lossless JXLs (e.g. Art/Screenshots)
CFG_LOSSLESS="PNG;6"
# 2. Target for Lossy JXLs (e.g. Photos)
CFG_LOSSY="JPEG;90"
# 3. Reconstruction Behavior
# true = If the JXL contains an original JPEG bitstream, restore that exact JPEG.
# false = Ignore hidden JPEG data and re-encode using CFG_LOSSLESS/LOSSY rules.
CFG_PREFER_RECONSTRUCTION=true
# Temporary directory for converted files
TMP_DIR="/tmp/jxl_transcode"
# ==============================================================================
# --- INITIALIZATION ---
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR"
echo "--- JPEG-XL Clipboard Autoconvert Watchdog ---"
echo " [Cfg] Lossless Target: $CFG_LOSSLESS"
echo " [Cfg] Lossy Target: $CFG_LOSSY"
echo " [Cfg] Reconstruct JPEG: $CFG_PREFER_RECONSTRUCTION"
echo " [Msg] Waiting for copy events..."
# Check dependencies
for cmd in xclip qdbus dbus-monitor magick djxl jxlinfo python3; do
if ! command -v $cmd &> /dev/null; then
echo " [Err] Missing dependency: '$cmd'. Please install it."
exit 1
fi
done
# Internal state to prevent loop-backs
LAST_SEEN_TEXT=""
# --- HELPER: Python URI Parser ---
# Handles URL encoding/decoding (e.g. "%20" -> " ") reliably
PY_PARSER=$(cat <<EOF
import sys, urllib.parse, pathlib
action = sys.argv[1]
data = sys.stdin.read().strip().splitlines()
if action == 'decode':
for line in data:
print(urllib.parse.unquote(line).replace('file://', '').strip())
elif action == 'encode':
for line in data:
if line.strip():
print(pathlib.Path(line).as_uri())
EOF
)
# --- HELPER: Image Converter ---
convert_with_config() {
local IN="$1"; local BASE="$2"; local CFG="$3"
local FMT="${CFG%;*}"; local VAL="${CFG#*;}"
local ARGS=(); local EXT=""
case "${FMT^^}" in
JPEG|JPG) EXT=".jpg"; ARGS=(-quality "$VAL") ;;
PNG) EXT=".png"; ARGS=(-define png:compression-level="$VAL") ;;
WEBP) EXT=".webp"; ARGS=(-quality "$VAL") ;;
WEBP_LOSSLESS) EXT=".webp"; ARGS=(-define webp:lossless=true -quality "$VAL") ;;
AVIF) EXT=".avif"; ARGS=(-quality "$VAL") ;;
AVIF_LOSSLESS) EXT=".avif"; ARGS=(-define heic:lossless=true -quality "$VAL") ;;
*) EXT=".jpg"; ARGS=(-quality 90) ;;
esac
local OUT="$BASE$EXT"
magick "$IN" "${ARGS[@]}" "$OUT"
if [[ -f "$OUT" ]]; then echo "$OUT"; fi
}
# --- MAIN LOOP ---
# Listens to KDE's clipboard signal without polling
dbus-monitor "interface='org.kde.klipper.klipper',member='clipboardHistoryUpdated'" 2>/dev/null | while read -r line; do
if echo "$line" | grep -q "clipboardHistoryUpdated"; then
# 1. READ (Invisible)
# Check clipboard content via IPC. Does NOT create a Wayland surface.
CLIP_TEXT=$(qdbus org.kde.klipper /klipper getClipboardContents 2>/dev/null)
# 2. ANTI-ECHO
# If this content matches what we just wrote, ignore it.
if [[ "$CLIP_TEXT" == "$LAST_SEEN_TEXT" ]]; then
continue
fi
# 3. PARSE
# Quick string check before doing heavy lifting
if echo "$CLIP_TEXT" | grep -iq "\.jxl"; then
# Short sleep to ensure file locks are released by the source app
sleep 0.2
FILE_PATHS=$(echo "$CLIP_TEXT" | python3 -c "$PY_PARSER" "decode")
NEW_FILE_LIST=""
DID_CONVERT=0
HAS_JXL=0
while IFS= read -r FILE_PATH; do
if [[ -z "$FILE_PATH" ]]; then continue; fi
# Verify file exists and is JXL
if [[ "${FILE_PATH,,}" == *.jxl ]] && [[ -f "$FILE_PATH" ]]; then
HAS_JXL=1
FILENAME=$(basename -- "$FILE_PATH")
NAME_NO_EXT="${FILENAME%.*}"
BASE_OUT="$TMP_DIR/$NAME_NO_EXT"
RESULT_FILE=""
echo " [+] Processing: $FILENAME"
# Analyze Metadata
INFO=$(jxlinfo "$FILE_PATH" 2>/dev/null)
# LOGIC A: JPEG Bitstream Reconstruction
if $CFG_PREFER_RECONSTRUCTION && echo "$INFO" | grep -Fq "JPEG bitstream reconstruction data available"; then
if djxl "$FILE_PATH" "$BASE_OUT.jpg" >/dev/null 2>&1; then
RESULT_FILE="$BASE_OUT.jpg"
echo " -> [Bitstream] Reconstructed original JPEG."
fi
fi
# LOGIC B: Transcode (if A failed or skipped)
if [[ -z "$RESULT_FILE" ]]; then
if echo "$INFO" | grep -Fq "lossless"; then
# Lossless JXL -> PNG (or configured lossless format)
echo " -> [Lossless] Converting to $CFG_LOSSLESS..."
RESULT_FILE=$(convert_with_config "$FILE_PATH" "$BASE_OUT" "$CFG_LOSSLESS")
else
# Lossy JXL -> JPEG (or configured lossy format)
echo " -> [Lossy] Converting to $CFG_LOSSY..."
RESULT_FILE=$(convert_with_config "$FILE_PATH" "$BASE_OUT" "$CFG_LOSSY")
fi
fi
# Success Check
if [[ -f "$RESULT_FILE" ]]; then
NEW_FILE_LIST+="$RESULT_FILE"$'\n'
((DID_CONVERT++))
else
echo " -> [Error] Conversion failed. Keeping original."
NEW_FILE_LIST+="$FILE_PATH"$'\n'
fi
else
# Not a JXL, keep in the list
NEW_FILE_LIST+="$FILE_PATH"$'\n'
fi
done <<< "$FILE_PATHS"
# 4. WRITE (Stealth)
if [[ $DID_CONVERT -gt 0 ]]; then
FINAL_URIS=$(echo -n "$NEW_FILE_LIST" | python3 -c "$PY_PARSER" "encode")
# Update memory BEFORE writing to prevent loop
LAST_SEEN_TEXT="$FINAL_URIS"
echo " > Batch finished: $DID_CONVERT file(s) converted."
echo " > Updating clipboard via XWayland bridge..."
# Use xclip to update clipboard via X11 background bridge.
# This bypasses Wayland focus stealing logic.
echo -n "$FINAL_URIS" | xclip -selection clipboard -t text/uri-list
else
# Found JXLs but failed to convert, or text just contained ".jxl" string
LAST_SEEN_TEXT="$CLIP_TEXT"
fi
else
# Not relevant content, update memory to avoid re-scanning
LAST_SEEN_TEXT="$CLIP_TEXT"
fi
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment