776 lines
31 KiB
Bash
Executable File
776 lines
31 KiB
Bash
Executable File
#!/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
|
|
}
|
|
|
|
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<num_audio; i++ )); do
|
|
audio_pairs+=("$((i+1))" "${audio_labels[$i]}" "on")
|
|
done
|
|
|
|
audio_check=$(kdialog --title "Transmutate — Audio Tracks" --checklist \
|
|
"Select audio tracks to carry over:" "${audio_pairs[@]}") || exit
|
|
|
|
if [[ -n "$audio_check" ]]; then
|
|
while IFS=$'\t' read -r cid cstatus; do
|
|
[[ -z "$cid" ]] && continue
|
|
if [[ "$cstatus" == "on" ]]; then
|
|
# Map UI ID (1-based) back to the absolute index
|
|
real_idx_pos=$(( cid - 1 ))
|
|
map_audio+=("${audio_absolute_indices[$real_idx_pos]}")
|
|
fi
|
|
done <<< "$audio_check"
|
|
fi
|
|
fi
|
|
|
|
# --- Subtitle track selection ---
|
|
num_sub=${#sub_absolute_indices[@]}
|
|
if (( num_sub == 1 )); then
|
|
map_sub=("${sub_absolute_indices[0]}")
|
|
elif (( num_sub > 1 )); then
|
|
sub_pairs=()
|
|
for (( i=0; i<num_sub; i++ )); do
|
|
sub_pairs+=("$((i+1))" "${sub_labels[$i]}" "on")
|
|
done
|
|
|
|
sub_check=$(kdialog --title "Transmutate — Subtitle Tracks" --checklist \
|
|
"Select subtitle tracks to carry over:" "${sub_pairs[@]}") || exit
|
|
|
|
if [[ -n "$sub_check" ]]; then
|
|
while IFS=$'\t' read -r cid cstatus; do
|
|
[[ -z "$cid" ]] && continue
|
|
if [[ "$cstatus" == "on" ]]; then
|
|
real_idx_pos=$(( cid - 1 ))
|
|
map_sub+=("${sub_absolute_indices[$real_idx_pos]}")
|
|
fi
|
|
done <<< "$sub_check"
|
|
fi
|
|
fi
|
|
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_user_default=28
|
|
local crf_ffmpeg_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 (51 = best, 0 = worst):" \
|
|
"$crf_user_default") || exit
|
|
|
|
# Empty → accept default
|
|
if [[ -z "$crf_input" ]]; then
|
|
crf_input=$crf_user_default
|
|
break
|
|
fi
|
|
|
|
if [[ "$crf_input" =~ ^[0-9]+$ ]] && (( crf_input >= 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
|