🎉 | Added project

This commit is contained in:
2026-05-06 16:16:36 +03:00
parent 643884f102
commit 192a02df45
2 changed files with 814 additions and 1 deletions

View File

@@ -1,3 +1,26 @@
# Transmutate
Transmutate is a KDE desktop tool for converting images, videos, and audio files. It uses KDialog to provide interactive menus where users select the output format, set quality parameters, and choose audio/subtitle tracks. Under the hood, it leverages FFMPEG and ImageMagick to perform the actual transcoding.
Transmutate is a pure KDE desktop tool for converting images, videos, and audio files. It uses **KDialog** for all interactive prompts and leverages **FFmpeg** and **ImageMagick** under the hood to perform the actual transcoding.
## Usage
```bash
./transmutate.sh <path/to/image|video|audio>
```
## Features
- Select output format, quality, and audio/subtitle tracks via KDE dialogs
- Convert **images** to PNG, JPG, WebP, AVIF, GIF, or video formats (MP4, MKV, WebM, AVI, MOV)
- Convert **videos** to MP4, MKV, WebM, AVI, MOV, GIF, or animated WebP
- Convert **audio** to MP3, FLAC, WAV, OGG, M4A, or AAC
- Animated GIFs and WebPs can be converted to full video formats
- GIF/WebP animation options (looping)
- If multiple audio or subtitle tracks exist, choose which to include
- Handles filename conflicts by prompting to overwrite or auto-renaming
## Requirements
- **KDE / KDialog**
- **FFmpeg**
- **ImageMagick** (`magick` CLI) — optional, used for better GIF and WebP conversion

790
transmutate.sh Executable file
View File

@@ -0,0 +1,790 @@
#!/usr/bin/env bash
#
# transmutate.sh — Convert images, videos, and audio using ffmpeg + kdialog
# Pure KDE (kdialog only).
#
# Usage: ./transmutate.sh <path/to/image|video|audio>
#
set -euo pipefail
# ─── Helpers ────────────────────────────────────────────────────────────────────
usage() {
echo "Usage: $0 <path/to/image|video|audio>"
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