Last active
January 13, 2026 03:46
-
-
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
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
| #!/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