#!/usr/bin/env bash # # transmutate.sh — Convert images, videos, and audio using ffmpeg + kdialog # Pure KDE (kdialog only). # # Usage: ./transmutate.sh # set -euo pipefail # ─── Helpers ──────────────────────────────────────────────────────────────────── usage() { echo "Usage: $0 " exit 1 } die() { kdialog --title "Transmutate — Error" --msgbox "$1" || true exit 1 } quit() { exit 1 } has_magick() { command -v magick >/dev/null 2>&1 } # ─── ask_quality: per-media-type quality input box ───────────────────────────── # Pure kdialog — one --inputbox with appropriate label per media type. # Validates the input and re-prompts on invalid entry. # $1 = media type (image/video/audio), $2 = default value ask_quality() { local media="$1" local default_val="${2:-85}" local prompt min_val max_val case "$media" in image) prompt="Quality (0-100):" min_val=0; max_val=100 ;; video) # CRF scale: 0=best/lossless, 51=worst. Lower = better quality. prompt="Quality (51-0) CRF:" min_val=0; max_val=51 ;; audio) # Per-codec quality scale (varies). Default range 0-100 for simplicity. prompt="Quality (0-100):" min_val=0; max_val=100 ;; *) prompt="Quality (0-100):" min_val=0; max_val=100 ;; esac while true; do local input input=$(kdialog --title "Transmutate — $media" --inputbox "$prompt" "$default_val") \ || quit # Empty input → accept default if [[ -z "$input" ]]; then echo "$default_val" return 0 fi # Must be a positive integer within range if ! [[ "$input" =~ ^[0-9]+$ ]] || (( input < min_val || input > max_val )); then kdialog --title "Transmutate — Invalid Input" \ --msgbox "Please enter an integer between $min_val and $max_val." default_val="$input" # pre-fill so they can fix it continue fi echo "$input" return 0 done } # ─── 1. Argument check ───────────────────────────────────────────────────────── [[ $# -lt 1 ]] && usage INPUT_FILE="$1" [[ ! -f "$INPUT_FILE" ]] && die "File does not exist: $INPUT_FILE" # ─── 2. Detect media type ────────────────────────────────────────────────────── MIME_TYPE=$(file --mime-type -b "$INPUT_FILE" 2>/dev/null) || die "Cannot determine file type" MEDIA_TYPE="" is_animated=false case "$MIME_TYPE" in image/*) MEDIA_TYPE="image" ;; video/*) MEDIA_TYPE="video" ;; audio/*) MEDIA_TYPE="audio" ;; *) die "Unsupported file type: $MIME_TYPE" ;; esac # Detect animated GIF / animated WebP if [[ "$MEDIA_TYPE" == "image" ]]; then case "$MIME_TYPE" in image/gif) is_animated=true ;; image/webp) # Use ffprobe (not ffmpeg) and temp file to avoid pipefail issues when ffmpeg errors PROBE=$(mktemp) ffprobe -v error -show_entries stream=codec_type -of csv=p=0 "$INPUT_FILE" > "$PROBE" 2>/dev/null || true if grep -q video "$PROBE"; then is_animated=true fi rm -f "$PROBE" ;; esac fi # ─── 3. File / path helpers ──────────────────────────────────────────────────── INPUT_BASENAME=$(basename "$INPUT_FILE") INPUT_NOEXT="${INPUT_BASENAME%.*}" INPUT_DIR=$(dirname "$INPUT_FILE") # ─── 4. Format selection dialog ──────────────────────────────────────────────── case "$MEDIA_TYPE" in image) # Build menu items as an array so each value/label pair is a separate argument MENU_ARGS=("png" "PNG" "jpg" "JPG" "webp" "WebP" "avif" "AVIF") if $is_animated || [[ "$MIME_TYPE" == "image/gif" ]] || [[ "$MIME_TYPE" == "image/webp" ]]; then MENU_ARGS+=("gif" "GIF" "mp4" "MP4" "mkv" "MKV" "webm" "WebM" "avi" "AVI" "mov" "MOV") fi SELECTED=$(kdialog --title "Transmutate — Image Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "${MENU_ARGS[@]}") || quit ;; video) SELECTED=$(kdialog --title "Transmutate — Video Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "mp4" "MP4" \ "mkv" "MKV" \ "webm" "WebM" \ "avi" "AVI" \ "mov" "MOV" \ "gif" "GIF" \ "webp" "WebP (animated)") || quit ;; audio) SELECTED=$(kdialog --title "Transmutate — Audio Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "mp3" "MP3" \ "flac" "FLAC" \ "wav" "WAV" \ "ogg" "OGG" \ "m4a" "M4A" \ "aac" "AAC") || quit ;; esac TARGET="$SELECTED" echo "DEBUG: SELECTED='$SELECTED' TARGET='$TARGET'" # ─── 4.5. Stream selection (video/audio only) ────────────────────────────────── # Probes the file for audio and subtitle tracks. # Single track → auto-carried. Multiple tracks → checklist to pick. # No selection for audio means "take first audio". No selection for subs means "none". # Arrays to hold selected stream indices map_audio=() # ffmpeg indices of audio streams to carry over map_sub=() # ffmpeg indices of subtitle streams to carry over if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then # Probe all streams: index, codec_type, codec_name, language # Use key=value output — ffprobe csv doesn't guarantee field order STREAMS_RAW=$(ffprobe -v error \ -show_entries stream=index,codec_type,codec_name,language \ -of default=noprint_wrappers=1 "$INPUT_FILE" 2>/dev/null) || STREAMS_RAW="" # Parse key=value pairs per stream block # Each stream has: index=X, codec_type=..., codec_name=..., language=... idx=0 audio_entries=() audio_real_idx=() sub_entries=() sub_real_idx=() current_idx="" current_type="" current_name="" current_lang="" while IFS='=' read -r key value; do # Strip whitespace from key key=$(echo "$key" | tr -d ' ') value=$(echo "$value" | tr -d ' ') case "$key" in index) current_idx="$value" ;; codec_type) current_type="$value" ;; codec_name) current_name="$value" ;; language) current_lang="$value" ;; esac # When we have a complete stream (after index or type), process it if [[ -n "$current_idx" && -n "$current_type" ]]; then if [[ "$current_type" == "audio" ]]; then if [[ -n "$current_lang" && "$current_lang" != "und" ]]; then label="Audio #$current_idx — $current_name (${current_lang})" else label="Audio #$current_idx — $current_name" fi audio_entries+=("$((idx + 1))" "$label" "on") audio_real_idx+=("$idx") idx=$(( idx + 1 )) elif [[ "$current_type" == "subtitle" ]]; then if [[ -n "$current_lang" && "$current_lang" != "und" ]]; then label="Subtitle #$current_idx — $current_name (${current_lang})" else label="Subtitle #$current_idx — $current_name" fi sub_entries+=("$((idx + 1))" "$label" "on") sub_real_idx+=("$idx") idx=$(( idx + 1 )) fi # Reset for next stream current_idx="" current_type="" current_name="" current_lang="" fi done <<< "$STREAMS_RAW" # --- Audio track selection --- # Convert checklist entries to (id label on/off) pairs for kdialog audio_pairs=() for (( a=0; a<${#audio_entries[@]}; a+=3 )); do audio_pairs+=("${audio_entries[$a]}" "${audio_entries[$((a+1))]}" "${audio_entries[$((a+2))]:-on}") done num_audio=$(( ${#audio_real_idx[@]} )) if (( num_audio == 0 )); then : # no audio streams, nothing to map elif (( num_audio == 1 )); then # Single audio stream — auto-select it map_audio=("${audio_real_idx[0]}") elif (( num_audio > 1 )); then # Multiple audio streams — let user pick audio_check=$(kdialog --title "Transmutate — Audio Tracks" --checklist \ "Select audio tracks to carry over:" \ "${audio_pairs[@]}") || quit if [[ -n "$audio_check" ]]; then while IFS=$'\t' read -r cid cstatus; do [[ -z "$cid" ]] && continue if [[ "$cstatus" == "on" ]]; then # Map checklist id (1-based) to real index real=$(( cid - 1 )) map_audio+=("${audio_real_idx[$real]}") fi done <<< "$audio_check" fi fi # --- Subtitle track selection --- sub_pairs=() for (( s=0; s<${#sub_entries[@]}; s+=3 )); do sub_pairs+=("${sub_entries[$s]}" "${sub_entries[$((s+1))]}" "${sub_entries[$((s+2))]:-on}") done num_sub=${#sub_real_idx[@]} if (( num_sub == 0 )); then : # no subtitles elif (( num_sub == 1 )); then # Single subtitle — auto-select map_sub=("${sub_real_idx[0]}") elif (( num_sub > 1 )); then sub_check=$(kdialog --title "Transmutate — Subtitle Tracks" --checklist \ "Select subtitle tracks to carry over:" \ "${sub_pairs[@]}") || quit if [[ -n "$sub_check" ]]; then while IFS=$'\t' read -r cid cstatus; do [[ -z "$cid" ]] && continue if [[ "$cstatus" == "on" ]]; then real=$(( cid - 1 )) map_sub+=("${sub_real_idx[$real]}") fi done <<< "$sub_check" fi fi echo "DEBUG: map_audio=[${map_audio[*]}] map_sub=[${map_sub[*]}]" fi # ─── 5. Quality / options dialog ─────────────────────────────────────────────── LOOPING=false # ─── ask_video_quality: combined video + audio quality dialog ────────────────── # Pure kdialog — presents two sequential inputboxes in one function call # (kdialog doesn't support multi-field single dialogs, so we show them # one after the other with full labels and validation per field). # $1 = target format (mp4/mkv/webm/avi/mov) # Outputs two lines: first is CRF, second is audio quality. ask_video_quality() { local target="$1" local crf_default=23 local audio_default=85 local audio_label audio_range audio_hint case "$target" in mp4|mov) audio_default=85 audio_label="AAC Audio Bitrate" audio_range="(1=32kbps, 100=320kbps, default 85)" ;; mkv) audio_default=5 audio_label="FLAC Compression Level" audio_range="(0=fastest, 8=best, default 5)" ;; webm) audio_default=5 audio_label="Opus Audio Bitrate" audio_range="(0=32kbps, 10=256kbps, default 5)" ;; avi) audio_default=8 audio_label="MP3 Audio Bitrate" audio_range="(0=128kbps, 9=320kbps, default 8)" ;; *) audio_default=85 audio_label="Audio Quality" audio_range="(1=best, 100=worst)" ;; esac # ── Field 1: Video CRF ─────────────────────────────────────────────── while true; do local crf_input crf_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \ "Video CRF (0 = best, 51 = worst):" \ "$crf_default") || quit # Empty → accept default if [[ -z "$crf_input" ]]; then crf_input=$crf_default break fi if [[ "$crf_input" =~ ^[0-9]+$ ]] && (( crf_input >= 0 && crf_input <= 51 )); then crf_default="$crf_input" break fi kdialog --title "Transmutate — Invalid CRF" \ --msgbox "Please enter an integer between 0 and 51." done # ── Field 2: Audio Quality ─────────────────────────────────────────── while true; do local audio_input audio_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \ "${audio_label} ${audio_range}:" \ "$audio_default") || quit # Empty → accept default if [[ -z "$audio_input" ]]; then audio_input=$audio_default break fi if [[ "$audio_input" =~ ^[0-9]+$ ]]; then audio_default="$audio_input" break fi kdialog --title "Transmutate — Invalid Audio Quality" \ --msgbox "Please enter a valid integer." done echo "$crf_default $audio_default" } # Per-media-type quality picker: image=0-100(default 85), video=CRF 0-51(default 23), audio=0-100(default 85) # But video→GIF/WebP uses 0-100 (same as images) since the output encoders use that scale. AUDIO_QUALITY="" case "$MEDIA_TYPE" in image) if $is_animated && [[ "$TARGET" == "mp4" || "$TARGET" == "mkv" || "$TARGET" == "webm" || "$TARGET" == "avi" || "$TARGET" == "mov" ]]; then # Animated image → video: combined dialog for both video CRF and audio quality read -r VIDEO_QUALITY AUDIO_QUALITY <<< $(ask_video_quality "$TARGET") QUALITY="$VIDEO_QUALITY" else QUALITY=$(ask_quality image 85) fi ;; audio) QUALITY=$(ask_quality audio 85) ;; video) if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then QUALITY=$(ask_quality image 50) # 0-100 scale, default 50 else # Video → video: combined dialog for both video CRF and audio quality read -r VIDEO_QUALITY AUDIO_QUALITY <<< $(ask_video_quality "$TARGET") QUALITY="$VIDEO_QUALITY" fi ;; esac echo "Quality=$QUALITY" echo "AudioQuality=$AUDIO_QUALITY" # GIF & WebP get extra animation options if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then # Video source → animated format: always animated, just ask about looping if [[ "$MEDIA_TYPE" == "video" ]]; then LOOPING=false if kdialog --title "Transmutate — Animation Settings" --yesno \ "Loop output?" 2>/dev/null; then LOOPING=true fi elif $is_animated; then CHECKS=$(kdialog --title "Transmutate — Animation Settings" --checklist \ "Options for: $INPUT_BASENAME" \ "1" "Animated" "on" \ "2" "Looping" "off") || quit # Kdialog returns space-separated quoted IDs, e.g. "1" "2" or just "2" # Check if "2" (Looping) is in the selection if [[ -n "$CHECKS" ]] && [[ "$CHECKS" == *"2"* ]]; then LOOPING=true fi else # Single non-animated image: ask about looping with --yesno LOOPING=false if kdialog --title "Transmutate — Animation Settings" --yesno \ "Loop output?\n\n(For single images, looping has no effect)" 2>/dev/null; then LOOPING=true fi fi fi echo "Looping=$LOOPING" # ─── 6. Output filename ──────────────────────────────────────────────────────── case "$TARGET" in png) OUT_EXT="png" ;; jpg) OUT_EXT="jpg" ;; gif) OUT_EXT="gif" ;; webp) OUT_EXT="webp" ;; avif) OUT_EXT="avif" ;; mp4) OUT_EXT="mp4" ;; mkv) OUT_EXT="mkv" ;; webm) OUT_EXT="webm" ;; avi) OUT_EXT="avi" ;; mov) OUT_EXT="mov" ;; mp3) OUT_EXT="mp3" ;; flac) OUT_EXT="flac" ;; wav) OUT_EXT="wav" ;; ogg) OUT_EXT="ogg" ;; m4a) OUT_EXT="m4a" ;; aac) OUT_EXT="aac" ;; *) die "Unknown format: $TARGET" ;; esac OUTPUT_FILE="${INPUT_DIR}/${INPUT_NOEXT}.${OUT_EXT}" # ─── 7. Handle file-overwrite ────────────────────────────────────────────────── if [[ -e "$OUTPUT_FILE" ]]; then if ! kdialog --title "File Exists" --yesno \ "A file with this name already exists:\n\n $OUTPUT_FILE\n\nOverwrite?" 2>/dev/null; then # User said No — auto-rename to avoid collision counter=1 while true; do renamed="${INPUT_DIR}/${INPUT_NOEXT}[$counter].${OUT_EXT}" [[ -e "$renamed" ]] || break counter=$(( counter + 1 )) done OUTPUT_FILE="$renamed" fi # User said Yes — keep the path, ffmpeg will overwrite fi echo "Output: $OUTPUT_FILE" # ─── 8. Build ffmpeg command ──────────────────────────────────────────────────── # For images/audio: user-facing 0-100, ffmpeg q-scale is inverted (lower=better). # For video: user enters CRF directly (0=best, 51=worst) — no inversion needed. Q="" if [[ "$MEDIA_TYPE" == "video" ]]; then CRF="$QUALITY" # CRF used directly else # Invert: user 100 (best) → ffmpeg q=1 (best), user 0 (worst) → ffmpeg q=100 (worst) Q=$(( 100 - QUALITY )) fi # ── Pre-compute audio quality argument (needed for -map decision) ────────── AQ_ARG="" if [[ "$MEDIA_TYPE" == "video" && -n "$AUDIO_QUALITY" ]]; then case "$TARGET" in mp4|mov) # AAC: use bitrate (kbps). Quality 1-100 maps to 32-320 kbps AQ=$(( AUDIO_QUALITY * 288 / 100 + 32 )) [[ $AQ -lt 32 ]] && AQ=32 [[ $AQ -gt 320 ]] && AQ=320 AQ_ARG="-c:a aac -b:a ${AQ}k" ;; mkv) # FLAC: compression level 0-8 (0=fastest, 8=best) AQ_ARG="-c:a flac -compression_level $AUDIO_QUALITY" ;; webm) # Opus: use bitrate (kbps). Quality 0-10 maps to 32-256 kbps AQ=$(( AUDIO_QUALITY * 224 / 10 + 32 )) [[ $AQ -lt 32 ]] && AQ=32 [[ $AQ -gt 256 ]] && AQ=256 AQ_ARG="-c:a libopus -b:a ${AQ}k" ;; avi) # MP3: use bitrate (kbps). Quality 0-9 maps to 128-320 kbps AQ=$(( AUDIO_QUALITY * 192 / 9 + 128 )) [[ $AQ -lt 128 ]] && AQ=128 [[ $AQ -gt 320 ]] && AQ=320 AQ_ARG="-c:a libmp3lame -b:a ${AQ}k" ;; esac fi # Build -map arguments for video streams # -map 0:v:0 = always map first video stream # -map 0:a:idx = map selected audio streams (stream-copy when no -c:a) # -map 0:s:idx = map selected subtitle streams # When AQ_ARG is set → add audio map + -c:a forces re-encode # When AQ_ARG is empty → only add audio map for stream-copy MAP_ARGS="-map 0:v:0" if (( ${#map_audio[@]} > 0 )); then for ai in "${map_audio[@]}"; do MAP_ARGS+=" -map 0:a:$ai" done elif [[ -n "$AQ_ARG" ]]; then # Fallback if no audio selected but we need audio MAP_ARGS+=" -map 0:a:0" fi if (( ${#map_sub[@]} > 0 )); then for si in "${map_sub[@]}"; do MAP_ARGS+=" -map 0:s:$si" done fi CMD="" case "$MEDIA_TYPE" in image) case "$TARGET" in png) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vframes 1 -q:v $(( Q < 1 ? 1 : Q )) -pix_fmt rgb24 '${OUTPUT_FILE}'" ;; jpg) JQ=$(( Q < 1 ? 1 : Q )) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vframes 1 -q:v $JQ -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; gif) # WebP → GIF: use ImageMagick (more reliable, handles corrupt WebP) # Falls back to ffmpeg if magick is not available if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "gif" ]] && has_magick; then # ImageMagick: quality 1-100 (lower=better), loop 0=infinite if $LOOPING; then CMD="magick '${INPUT_FILE}' -quality $QUALITY -loop 0 '${OUTPUT_FILE}'" else CMD="magick '${INPUT_FILE}' -quality $QUALITY '${OUTPUT_FILE}'" fi else # ffmpeg fallback (non-WebP or no magick) if $is_animated; then SRC_FPS=$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "25") SRC_W=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "0") SRC_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "0") FPS="$SRC_FPS" SCALE="${SRC_W}:${SRC_H}:flags=lanczos" # Map quality (0-100) to palette colors (3-256) # Minimum 3 because ffmpeg doesn't support max_colors=2 with reserve_transparent IMG_PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 )) echo "DEBUG: image→GIF fallback QUALITY=$QUALITY IMG_PALETTE_COLORS=$IMG_PALETTE_COLORS" if $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vf 'fps=$FPS,scale=$SCALE,split[s0][s1];[s0]palettegen=max_colors=$IMG_PALETTE_COLORS:reserve_transparent=1[p];[s1][p]paletteuse=dither=sierra2_4a' -loop 0 '${OUTPUT_FILE}'" else CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vf 'fps=$FPS,scale=$SCALE,split[s0][s1];[s0]palettegen=max_colors=$IMG_PALETTE_COLORS:reserve_transparent=1[p];[s1][p]paletteuse=dither=sierra2_4a' '${OUTPUT_FILE}'" fi else if $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vframes 1 -f gif -loop 0 '${OUTPUT_FILE}'" else CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vframes 1 -f gif '${OUTPUT_FILE}'" fi fi fi ;; webp) # ImageMagick handles both animated and static images for WebP # ImageMagick quality: 0=worst, 100=best (same as user-facing scale) if has_magick; then if $LOOPING; then CMD="magick '${INPUT_FILE}' -loop 0 -quality $QUALITY '${OUTPUT_FILE}'" else CMD="magick '${INPUT_FILE}' -quality $QUALITY '${OUTPUT_FILE}'" fi else # ffmpeg fallback: use libwebp with q:v scale (0=best, 255=worst) WQ=$(( 255 - (Q * 255 / 100) )) [[ $WQ -lt 0 ]] && WQ=0 if $is_animated || $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -q:v $WQ -loop 0 -frames:v 0 '${OUTPUT_FILE}'" else CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -q:v $WQ -vframes 1 '${OUTPUT_FILE}'" fi fi ;; avif) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vframes 1 -q:v $(( Q < 1 ? 1 : Q )) '${OUTPUT_FILE}'" ;; mp4) # Animated image → MP4: use libwebp/ffmpeg to extract frames, re-encode as h264 CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" ;; mkv) # Animated image → MKV CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; webm) # Animated image → WebM CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; avi) # Animated image → AVI CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY '${OUTPUT_FILE}'" ;; mov) # Animated image → MOV CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; esac ;; video) case "$TARGET" in mp4) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" ;; mkv) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; webm) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; avi) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY '${OUTPUT_FILE}'" ;; mov) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; gif) # Video → GIF: use palettegen with quality-based dithering # Quality 0-100 (higher=better). Map to paletteuse dither level: # 0 → bayer with scale 7 (worst, most aggressive compression) # 100 → sierra2_4a (best dithering) SRC_FPS=$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "25") SRC_W=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "0") SRC_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "0") SCALE="${SRC_W}:${SRC_H}:flags=lanczos" # If fps is a fraction like 24000/1001, keep as-is; otherwise use as integer if [[ "$SRC_FPS" == */* ]]; then FPS="$SRC_FPS" else FPS="$SRC_FPS" fi # Map quality (0-100) to palette generation colors (3-256) # Minimum 3 colors because ffmpeg doesn't support max_colors=2 with reserve_transparent PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 )) # Map quality to dither method if (( QUALITY >= 75 )); then DITHER="sierra2_4a" elif (( QUALITY >= 50 )); then DITHER="bayer:bayer_scale=3" elif (( QUALITY >= 25 )); then DITHER="bayer:bayer_scale=2" else DITHER="none" fi echo "DEBUG: video→GIF QUALITY=$QUALITY PALETTE_COLORS=$PALETTE_COLORS DITHER=$DITHER" if $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vf 'fps=$FPS,scale=$SCALE,split[s0][s1];[s0]palettegen=max_colors=$PALETTE_COLORS:reserve_transparent=1[p];[s1][p]paletteuse=dither=$DITHER' -loop 0 '${OUTPUT_FILE}'" else CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vf 'fps=$FPS,scale=$SCALE,split[s0][s1];[s0]palettegen=max_colors=$PALETTE_COLORS:reserve_transparent=1[p];[s1][p]paletteuse=dither=$DITHER' '${OUTPUT_FILE}'" fi ;; webp) # Video → animated WebP: use ffmpeg with libwebp_anim # Quality 0-100 (higher=better) used directly as libwebp -quality if $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -c:v libwebp_anim -lossless 0 -quality $QUALITY -loop 0 '${OUTPUT_FILE}'" else CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -c:v libwebp_anim -lossless 0 -quality $QUALITY '${OUTPUT_FILE}'" fi ;; esac ;; audio) # For pure audio files, map selected audio stream (or all if none selected) AUDIO_MAP="-map 0:a:0" if (( ${#map_audio[@]} > 0 )); then AUDIO_MAP="" for ai in "${map_audio[@]}"; do AUDIO_MAP+="-map 0:a:$ai " done fi case "$TARGET" in mp3) AQ=$(( (100 - QUALITY) / 11 )) [[ $AQ -lt 0 ]] && AQ=0 [[ $AQ -gt 9 ]] && AQ=9 CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $AUDIO_MAP -q:a $AQ '${OUTPUT_FILE}'" ;; flac) FQ=$(( QUALITY * 8 / 100 )) [[ $FQ -gt 8 ]] && FQ=8 CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $AUDIO_MAP -compression_level $(( FQ + 1 )) '${OUTPUT_FILE}'" ;; wav) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $AUDIO_MAP -acodec pcm_s16le '${OUTPUT_FILE}'" ;; ogg) OQ=$(( (100 - QUALITY) / 10 )) [[ $OQ -lt 0 ]] && OQ=0 [[ $OQ -gt 10 ]] && OQ=10 CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $AUDIO_MAP -q:a $OQ '${OUTPUT_FILE}'" ;; m4a) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $AUDIO_MAP -q:a $(( Q < 1 ? 1 : Q )) '${OUTPUT_FILE}'" ;; aac) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $AUDIO_MAP -q:a $(( Q < 1 ? 1 : Q )) '${OUTPUT_FILE}'" ;; esac ;; esac # ─── 9. Pre-flight input check ───────────────────────────────────────────────── # Verify ffmpeg can actually read the input before attempting conversion. # Catches corrupt/empty files early with a clearer error. # Skipped for WebP→GIF since ImageMagick handles those better. USE_MAGICK=false if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "gif" ]] && has_magick; then USE_MAGICK=true fi if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "webp" ]] && has_magick; then USE_MAGICK=true fi ERRFILE=$(mktemp) if [[ "$USE_MAGICK" == "false" ]] && ! ffmpeg -v error -i "${INPUT_FILE}" -f null - > /dev/null 2>"${ERRFILE}"; then # Check if errors indicate unreadable input (corrupt/empty files) if grep -qiE 'image data not found|Invalid data|Cannot determine format|decoding error|unspecified size' "${ERRFILE}"; then ERR_MSG=$(head -5 "${ERRFILE}" | grep -iE 'image data|Invalid data|Cannot determine|decoding error|unspecified size' | head -3 | sed 's/\[.*\]//g' | sed 's/^[[:space:]]*//' | paste -sd ' ') || true die "Input file appears to be corrupted or empty.\n\n${ERR_MSG:-See terminal output for details.}" fi fi rm -f "${ERRFILE}" # ─── 10. Execute ──────────────────────────────────────────────────────────────── echo "" echo "Executing:" echo " $CMD" echo "" if eval "$CMD"; then OUT_BASE=$(basename "$OUTPUT_FILE") kdialog --title "Transmutate — Success" --msgbox \ "Conversion complete!\n\nInput: $INPUT_BASENAME\nOutput: $OUT_BASE" else kdialog --title "Transmutate — Error" --msgbox \ "Conversion failed.\n\nSee terminal output for details." exit 1 fi