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