#!/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 } 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 local input case "$media" in image) prompt="Quality (0-100):" min_val=0; max_val=100 ;; video) # CRF scale flipped: 51=best/lossless, 0=worst. Higher = 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 input=$(kdialog --title "Transmutate — $media" --inputbox "$prompt" "$default_val") \ || exit # Empty input → accept default (invert for video) if [[ -z "$input" ]]; then if [[ "$media" == "video" ]]; then echo $(( 51 - default_val )) else echo "$default_val" fi 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 # ── Invert CRF for video: user 51 (best) → ffmpeg 0, user 0 (worst) → ffmpeg 51 ── if [[ "$media" == "video" ]]; then local crf_inverted=$(( 51 - input )) echo "$crf_inverted" else echo "$input" fi 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 TARGET=$(kdialog --title "Transmutate — Image Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "${MENU_ARGS[@]}") || exit ;; video) TARGET=$(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)") || exit ;; audio) TARGET=$(kdialog --title "Transmutate — Audio Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "mp3" "MP3" \ "flac" "FLAC" \ "wav" "WAV" \ "ogg" "OGG" \ "m4a" "M4A" \ "aac" "AAC") || exit ;; esac # ─── 4.5. Stream selection (video/audio only) ────────────────────────────────── map_audio=() map_sub=() if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then # Probe all streams 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="" # We will store the ABSOLUTE ffmpeg indices here audio_absolute_indices=() audio_labels=() sub_absolute_indices=() sub_labels=() # Temporary variables for parsing current_idx="" current_type="" current_name="" current_lang="" while IFS='=' read -r key value; do 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 block if [[ -n "$current_idx" && -n "$current_type" ]]; then if [[ "$current_type" == "audio" ]]; then label="Audio #$current_idx — $current_name${current_lang:+ (${current_lang})}" audio_absolute_indices+=("$current_idx") audio_labels+=("$label") elif [[ "$current_type" == "subtitle" ]]; then label="Subtitle #$current_idx — $current_name${current_lang:+ (${current_lang})}" sub_absolute_indices+=("$current_idx") sub_labels+=("$label") fi # Reset current_idx="" current_type="" current_name="" current_lang="" fi done <<< "$STREAMS_RAW" # --- Audio track selection --- num_audio=${#audio_absolute_indices[@]} if (( num_audio == 1 )); then map_audio=("${audio_absolute_indices[0]}") elif (( num_audio > 1 )); then audio_pairs=() for (( i=0; i 1 )); then sub_pairs=() for (( i=0; i= 0 && crf_input <= 51 )); then # Invert: user 51 (best) → ffmpeg 0, user 0 (worst) → ffmpeg 51 crf_ffmpeg_default=$(( 51 - 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") || exit # 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_ffmpeg_default $audio_default" } # Per-media-type quality picker: image=0-100(default 85), video=CRF 51-0(default 28), 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, AudioQuality=${AUDIO_QUALITY:-unset}" # 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") || exit # 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-facing CRF is 51-0 (higher=better), inverted to ffmpeg 0-51 (lower=better). Q="" if [[ "$MEDIA_TYPE" == "video" ]]; then # Already inverted in ask_video_quality or ask_quality, use 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 # ── Pre-compute subtitle codec argument ────────────────────────── SQ_ARG="" if (( ${#map_sub[@]} > 0 )); then case "$TARGET" in mp4|mov) SQ_ARG="-c:s mov_text" ;; mkv) SQ_ARG="-c:s copy" ;; webm) SQ_ARG="-c:s webvtt" ;; avi) SQ_ARG="-c:s copy" ;; esac fi # Build -map arguments for streams # We use absolute indices (e.g., -map 0:1) instead of category indices (e.g., -map 0:a:0) MAP_ARGS="-map 0:v:0" if (( ${#map_audio[@]} > 0 )); then for ai in "${map_audio[@]}"; do MAP_ARGS+=" -map 0:$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:$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 )) 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 $SQ_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 $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; webm) # Animated image → WebM CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; avi) # Animated image → AVI CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY '${OUTPUT_FILE}'" ;; mov) # Animated image → MOV CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_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 $SQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" ;; mkv) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; webm) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'" ;; avi) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY '${OUTPUT_FILE}'" ;; mov) CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_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" FPS="$SRC_FPS" # 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 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:$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" ]] || [[ "$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