♻ | New project structure
This commit is contained in:
26
legacy/README.md
Normal file
26
legacy/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Transmutate
|
||||
|
||||
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
|
||||
775
legacy/transmutate.sh
Executable file
775
legacy/transmutate.sh
Executable file
@@ -0,0 +1,775 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user