♻ | New project structure
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
.dist-info/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Distribution
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tar.gz
|
||||||
88
README.md
88
README.md
@@ -1,26 +1,84 @@
|
|||||||
# Transmutate
|
# Transmutate (Python GUI)
|
||||||
|
|
||||||
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.
|
A modern Python/CustomTkinter rewrite of the original `transmutate.sh` bash script.
|
||||||
|
Convert images, videos, and audio using ffmpeg + ImageMagick with a modern, cross-platform GUI.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- customtkinter (`pip install customtkinter`)
|
||||||
|
- Pillow (for animated image detection)
|
||||||
|
- ffmpeg (must be in PATH)
|
||||||
|
- magick / ImageMagick (optional, provides better WebP/GIF handling)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./transmutate.sh <path/to/image|video|audio>
|
cd rewrite
|
||||||
|
python transmutate.py <path/to/file>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The GUI opens automatically and detects the media type of the file.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Select output format, quality, and audio/subtitle tracks via KDE dialogs
|
### Supported Conversions
|
||||||
- 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
|
| Source → Target | Options |
|
||||||
|
|-----------------|---------|
|
||||||
|
| Image → Image | PNG, JPG, WebP, AVIF, GIF (animated), MP4, MKV, WebM, AVI, MOV (animated) |
|
||||||
|
| Image → Video | MP4, MKV, WebM, AVI, MOV |
|
||||||
|
| Image → Audio | MP3, FLAC, WAV, OGG, M4A, AAC |
|
||||||
|
| Video → Image | PNG, JPG, WebP, AVIF |
|
||||||
|
| Video → Video | MP4, MKV, WebM, AVI, MOV, GIF, WebP (animated) |
|
||||||
|
| Video → Audio | MP3, FLAC, WAV, OGG, M4A, AAC |
|
||||||
|
| Audio → Image | PNG, JPG, WebP, AVIF |
|
||||||
|
| Audio → Video | MP4, MKV, WebM, AVI, MOV |
|
||||||
|
| Audio → Audio | MP3, FLAC, WAV, OGG, M4A, AAC |
|
||||||
|
|
||||||
- **KDE / KDialog**
|
### Per-Conversion Options
|
||||||
- **FFmpeg**
|
|
||||||
- **ImageMagick** (`magick` CLI) — optional, used for better GIF and WebP conversion
|
- **Quality slider** (0-100, or 0-51 for video CRF) — smooth CustomTkinter slider
|
||||||
|
- **Audio quality** slider (for video output)
|
||||||
|
- **Audio track selection** (when multiple tracks exist)
|
||||||
|
- **Subtitle track selection** (when multiple tracks exist)
|
||||||
|
- **Loop toggle** (for GIF/WebP output)
|
||||||
|
- **Output path** — same directory as source, auto-renamed on conflict
|
||||||
|
|
||||||
|
### Conversion Modes
|
||||||
|
|
||||||
|
- **Image → Image**: Direct ffmpeg conversion with quality control
|
||||||
|
- **Image → Video**: Animated images are re-encoded with CRF
|
||||||
|
- **Image → Audio**: Single frame extracted to audio
|
||||||
|
- **Video → Image**: Single frame extraction
|
||||||
|
- **Video → Video**: Full conversion with CRF and audio quality control
|
||||||
|
- **Video → GIF**: Palettegen-based GIF with quality-dependent dithering
|
||||||
|
- **Video → WebP**: Animated WebP output
|
||||||
|
- **Audio → Audio**: Format conversion with quality control
|
||||||
|
- **Audio → Image**: First frame extracted as image
|
||||||
|
- **Audio → Video**: Single frame rendered as video
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
rewrite/
|
||||||
|
├── transmutate.py # Entry point (CLI → GUI)
|
||||||
|
├── transmutate_app/ # Package
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── gui.py # CustomTkinter GUI application
|
||||||
|
│ └── engine/ # Conversion engine
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── ffmpeg_engine.py # Command building + execution
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compared to Bash Version
|
||||||
|
|
||||||
|
| Feature | Bash (kdialog) | Python (CustomTkinter) |
|
||||||
|
|---------|---------------|------------------|
|
||||||
|
| Dependencies | bash, ffmpeg, kdialog | python3, customtkinter, ffmpeg |
|
||||||
|
| GUI | Multiple sequential popups | Single unified CustomTkinter window |
|
||||||
|
| Stream selection | kdialog checklist | CustomTkinter checkboxes |
|
||||||
|
| Quality input | kdialog inputbox | CustomTkinter slider |
|
||||||
|
| File overwrite | kdialog yes/no | CustomTkinter dialog + auto-rename |
|
||||||
|
| Animated detection | ffprobe | Pillow + ffprobe |
|
||||||
|
| Code organization | Single monolithic script | Modular package |
|
||||||
|
|||||||
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
|
||||||
@@ -20,10 +20,6 @@ die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
quit() {
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
has_magick() {
|
has_magick() {
|
||||||
command -v magick >/dev/null 2>&1
|
command -v magick >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
@@ -36,13 +32,14 @@ ask_quality() {
|
|||||||
local media="$1"
|
local media="$1"
|
||||||
local default_val="${2:-85}"
|
local default_val="${2:-85}"
|
||||||
local prompt min_val max_val
|
local prompt min_val max_val
|
||||||
|
local input
|
||||||
|
|
||||||
case "$media" in
|
case "$media" in
|
||||||
image)
|
image)
|
||||||
prompt="Quality (0-100):"
|
prompt="Quality (0-100):"
|
||||||
min_val=0; max_val=100 ;;
|
min_val=0; max_val=100 ;;
|
||||||
video)
|
video)
|
||||||
# CRF scale: 0=best/lossless, 51=worst. Lower = better quality.
|
# CRF scale flipped: 51=best/lossless, 0=worst. Higher = better quality.
|
||||||
prompt="Quality (51-0) CRF:"
|
prompt="Quality (51-0) CRF:"
|
||||||
min_val=0; max_val=51 ;;
|
min_val=0; max_val=51 ;;
|
||||||
audio)
|
audio)
|
||||||
@@ -55,13 +52,16 @@ ask_quality() {
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
local input
|
|
||||||
input=$(kdialog --title "Transmutate — $media" --inputbox "$prompt" "$default_val") \
|
input=$(kdialog --title "Transmutate — $media" --inputbox "$prompt" "$default_val") \
|
||||||
|| quit
|
|| exit
|
||||||
|
|
||||||
# Empty input → accept default
|
# Empty input → accept default (invert for video)
|
||||||
if [[ -z "$input" ]]; then
|
if [[ -z "$input" ]]; then
|
||||||
|
if [[ "$media" == "video" ]]; then
|
||||||
|
echo $(( 51 - default_val ))
|
||||||
|
else
|
||||||
echo "$default_val"
|
echo "$default_val"
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -73,7 +73,13 @@ ask_quality() {
|
|||||||
continue
|
continue
|
||||||
fi
|
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"
|
echo "$input"
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -131,12 +137,12 @@ case "$MEDIA_TYPE" in
|
|||||||
MENU_ARGS+=("gif" "GIF" "mp4" "MP4" "mkv" "MKV" "webm" "WebM" "avi" "AVI" "mov" "MOV")
|
MENU_ARGS+=("gif" "GIF" "mp4" "MP4" "mkv" "MKV" "webm" "WebM" "avi" "AVI" "mov" "MOV")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SELECTED=$(kdialog --title "Transmutate — Image Conversion" --menu \
|
TARGET=$(kdialog --title "Transmutate — Image Conversion" --menu \
|
||||||
"Choose output format for: $INPUT_BASENAME" \
|
"Choose output format for: $INPUT_BASENAME" \
|
||||||
"${MENU_ARGS[@]}") || quit
|
"${MENU_ARGS[@]}") || exit
|
||||||
;;
|
;;
|
||||||
video)
|
video)
|
||||||
SELECTED=$(kdialog --title "Transmutate — Video Conversion" --menu \
|
TARGET=$(kdialog --title "Transmutate — Video Conversion" --menu \
|
||||||
"Choose output format for: $INPUT_BASENAME" \
|
"Choose output format for: $INPUT_BASENAME" \
|
||||||
"mp4" "MP4" \
|
"mp4" "MP4" \
|
||||||
"mkv" "MKV" \
|
"mkv" "MKV" \
|
||||||
@@ -144,55 +150,46 @@ case "$MEDIA_TYPE" in
|
|||||||
"avi" "AVI" \
|
"avi" "AVI" \
|
||||||
"mov" "MOV" \
|
"mov" "MOV" \
|
||||||
"gif" "GIF" \
|
"gif" "GIF" \
|
||||||
"webp" "WebP (animated)") || quit
|
"webp" "WebP (animated)") || exit
|
||||||
;;
|
;;
|
||||||
audio)
|
audio)
|
||||||
SELECTED=$(kdialog --title "Transmutate — Audio Conversion" --menu \
|
TARGET=$(kdialog --title "Transmutate — Audio Conversion" --menu \
|
||||||
"Choose output format for: $INPUT_BASENAME" \
|
"Choose output format for: $INPUT_BASENAME" \
|
||||||
"mp3" "MP3" \
|
"mp3" "MP3" \
|
||||||
"flac" "FLAC" \
|
"flac" "FLAC" \
|
||||||
"wav" "WAV" \
|
"wav" "WAV" \
|
||||||
"ogg" "OGG" \
|
"ogg" "OGG" \
|
||||||
"m4a" "M4A" \
|
"m4a" "M4A" \
|
||||||
"aac" "AAC") || quit
|
"aac" "AAC") || exit
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
TARGET="$SELECTED"
|
|
||||||
echo "DEBUG: SELECTED='$SELECTED' TARGET='$TARGET'"
|
|
||||||
|
|
||||||
# ─── 4.5. Stream selection (video/audio only) ──────────────────────────────────
|
# ─── 4.5. Stream selection (video/audio only) ──────────────────────────────────
|
||||||
# Probes the file for audio and subtitle tracks.
|
map_audio=()
|
||||||
# Single track → auto-carried. Multiple tracks → checklist to pick.
|
map_sub=()
|
||||||
# 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
|
if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then
|
||||||
# Probe all streams: index, codec_type, codec_name, language
|
# Probe all streams
|
||||||
# Use key=value output — ffprobe csv doesn't guarantee field order
|
|
||||||
STREAMS_RAW=$(ffprobe -v error \
|
STREAMS_RAW=$(ffprobe -v error \
|
||||||
-show_entries stream=index,codec_type,codec_name,language \
|
-show_entries stream=index,codec_type,codec_name,language \
|
||||||
-of default=noprint_wrappers=1 "$INPUT_FILE" 2>/dev/null) || STREAMS_RAW=""
|
-of default=noprint_wrappers=1 "$INPUT_FILE" 2>/dev/null) || STREAMS_RAW=""
|
||||||
|
|
||||||
# Parse key=value pairs per stream block
|
# We will store the ABSOLUTE ffmpeg indices here
|
||||||
# Each stream has: index=X, codec_type=..., codec_name=..., language=...
|
audio_absolute_indices=()
|
||||||
idx=0
|
audio_labels=()
|
||||||
audio_entries=()
|
sub_absolute_indices=()
|
||||||
audio_real_idx=()
|
sub_labels=()
|
||||||
sub_entries=()
|
|
||||||
sub_real_idx=()
|
# Temporary variables for parsing
|
||||||
current_idx=""
|
current_idx=""
|
||||||
current_type=""
|
current_type=""
|
||||||
current_name=""
|
current_name=""
|
||||||
current_lang=""
|
current_lang=""
|
||||||
|
|
||||||
while IFS='=' read -r key value; do
|
while IFS='=' read -r key value; do
|
||||||
# Strip whitespace from key
|
|
||||||
key=$(echo "$key" | tr -d ' ')
|
key=$(echo "$key" | tr -d ' ')
|
||||||
value=$(echo "$value" | tr -d ' ')
|
value=$(echo "$value" | tr -d ' ')
|
||||||
|
|
||||||
case "$key" in
|
case "$key" in
|
||||||
index) current_idx="$value" ;;
|
index) current_idx="$value" ;;
|
||||||
codec_type) current_type="$value" ;;
|
codec_type) current_type="$value" ;;
|
||||||
@@ -200,28 +197,18 @@ if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then
|
|||||||
language) current_lang="$value" ;;
|
language) current_lang="$value" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# When we have a complete stream (after index or type), process it
|
# When we have a complete stream block
|
||||||
if [[ -n "$current_idx" && -n "$current_type" ]]; then
|
if [[ -n "$current_idx" && -n "$current_type" ]]; then
|
||||||
if [[ "$current_type" == "audio" ]]; then
|
if [[ "$current_type" == "audio" ]]; then
|
||||||
if [[ -n "$current_lang" && "$current_lang" != "und" ]]; then
|
label="Audio #$current_idx — $current_name${current_lang:+ (${current_lang})}"
|
||||||
label="Audio #$current_idx — $current_name (${current_lang})"
|
audio_absolute_indices+=("$current_idx")
|
||||||
else
|
audio_labels+=("$label")
|
||||||
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
|
elif [[ "$current_type" == "subtitle" ]]; then
|
||||||
if [[ -n "$current_lang" && "$current_lang" != "und" ]]; then
|
label="Subtitle #$current_idx — $current_name${current_lang:+ (${current_lang})}"
|
||||||
label="Subtitle #$current_idx — $current_name (${current_lang})"
|
sub_absolute_indices+=("$current_idx")
|
||||||
else
|
sub_labels+=("$label")
|
||||||
label="Subtitle #$current_idx — $current_name"
|
|
||||||
fi
|
fi
|
||||||
sub_entries+=("$((idx + 1))" "$label" "on")
|
# Reset
|
||||||
sub_real_idx+=("$idx")
|
|
||||||
idx=$(( idx + 1 ))
|
|
||||||
fi
|
|
||||||
# Reset for next stream
|
|
||||||
current_idx=""
|
current_idx=""
|
||||||
current_type=""
|
current_type=""
|
||||||
current_name=""
|
current_name=""
|
||||||
@@ -230,63 +217,53 @@ if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then
|
|||||||
done <<< "$STREAMS_RAW"
|
done <<< "$STREAMS_RAW"
|
||||||
|
|
||||||
# --- Audio track selection ---
|
# --- Audio track selection ---
|
||||||
# Convert checklist entries to (id label on/off) pairs for kdialog
|
num_audio=${#audio_absolute_indices[@]}
|
||||||
|
if (( num_audio == 1 )); then
|
||||||
|
map_audio=("${audio_absolute_indices[0]}")
|
||||||
|
elif (( num_audio > 1 )); then
|
||||||
audio_pairs=()
|
audio_pairs=()
|
||||||
for (( a=0; a<${#audio_entries[@]}; a+=3 )); do
|
for (( i=0; i<num_audio; i++ )); do
|
||||||
audio_pairs+=("${audio_entries[$a]}" "${audio_entries[$((a+1))]}" "${audio_entries[$((a+2))]:-on}")
|
audio_pairs+=("$((i+1))" "${audio_labels[$i]}" "on")
|
||||||
done
|
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 \
|
audio_check=$(kdialog --title "Transmutate — Audio Tracks" --checklist \
|
||||||
"Select audio tracks to carry over:" \
|
"Select audio tracks to carry over:" "${audio_pairs[@]}") || exit
|
||||||
"${audio_pairs[@]}") || quit
|
|
||||||
if [[ -n "$audio_check" ]]; then
|
if [[ -n "$audio_check" ]]; then
|
||||||
while IFS=$'\t' read -r cid cstatus; do
|
while IFS=$'\t' read -r cid cstatus; do
|
||||||
[[ -z "$cid" ]] && continue
|
[[ -z "$cid" ]] && continue
|
||||||
if [[ "$cstatus" == "on" ]]; then
|
if [[ "$cstatus" == "on" ]]; then
|
||||||
# Map checklist id (1-based) to real index
|
# Map UI ID (1-based) back to the absolute index
|
||||||
real=$(( cid - 1 ))
|
real_idx_pos=$(( cid - 1 ))
|
||||||
map_audio+=("${audio_real_idx[$real]}")
|
map_audio+=("${audio_absolute_indices[$real_idx_pos]}")
|
||||||
fi
|
fi
|
||||||
done <<< "$audio_check"
|
done <<< "$audio_check"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Subtitle track selection ---
|
# --- 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=()
|
sub_pairs=()
|
||||||
for (( s=0; s<${#sub_entries[@]}; s+=3 )); do
|
for (( i=0; i<num_sub; i++ )); do
|
||||||
sub_pairs+=("${sub_entries[$s]}" "${sub_entries[$((s+1))]}" "${sub_entries[$((s+2))]:-on}")
|
sub_pairs+=("$((i+1))" "${sub_labels[$i]}" "on")
|
||||||
done
|
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 \
|
sub_check=$(kdialog --title "Transmutate — Subtitle Tracks" --checklist \
|
||||||
"Select subtitle tracks to carry over:" \
|
"Select subtitle tracks to carry over:" "${sub_pairs[@]}") || exit
|
||||||
"${sub_pairs[@]}") || quit
|
|
||||||
if [[ -n "$sub_check" ]]; then
|
if [[ -n "$sub_check" ]]; then
|
||||||
while IFS=$'\t' read -r cid cstatus; do
|
while IFS=$'\t' read -r cid cstatus; do
|
||||||
[[ -z "$cid" ]] && continue
|
[[ -z "$cid" ]] && continue
|
||||||
if [[ "$cstatus" == "on" ]]; then
|
if [[ "$cstatus" == "on" ]]; then
|
||||||
real=$(( cid - 1 ))
|
real_idx_pos=$(( cid - 1 ))
|
||||||
map_sub+=("${sub_real_idx[$real]}")
|
map_sub+=("${sub_absolute_indices[$real_idx_pos]}")
|
||||||
fi
|
fi
|
||||||
done <<< "$sub_check"
|
done <<< "$sub_check"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "DEBUG: map_audio=[${map_audio[*]}] map_sub=[${map_sub[*]}]"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── 5. Quality / options dialog ───────────────────────────────────────────────
|
# ─── 5. Quality / options dialog ───────────────────────────────────────────────
|
||||||
@@ -301,7 +278,8 @@ LOOPING=false
|
|||||||
# Outputs two lines: first is CRF, second is audio quality.
|
# Outputs two lines: first is CRF, second is audio quality.
|
||||||
ask_video_quality() {
|
ask_video_quality() {
|
||||||
local target="$1"
|
local target="$1"
|
||||||
local crf_default=23
|
local crf_user_default=28
|
||||||
|
local crf_ffmpeg_default=23
|
||||||
local audio_default=85
|
local audio_default=85
|
||||||
local audio_label audio_range audio_hint
|
local audio_label audio_range audio_hint
|
||||||
|
|
||||||
@@ -337,17 +315,18 @@ ask_video_quality() {
|
|||||||
while true; do
|
while true; do
|
||||||
local crf_input
|
local crf_input
|
||||||
crf_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \
|
crf_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \
|
||||||
"Video CRF (0 = best, 51 = worst):" \
|
"Video CRF (51 = best, 0 = worst):" \
|
||||||
"$crf_default") || quit
|
"$crf_user_default") || exit
|
||||||
|
|
||||||
# Empty → accept default
|
# Empty → accept default
|
||||||
if [[ -z "$crf_input" ]]; then
|
if [[ -z "$crf_input" ]]; then
|
||||||
crf_input=$crf_default
|
crf_input=$crf_user_default
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$crf_input" =~ ^[0-9]+$ ]] && (( crf_input >= 0 && crf_input <= 51 )); then
|
if [[ "$crf_input" =~ ^[0-9]+$ ]] && (( crf_input >= 0 && crf_input <= 51 )); then
|
||||||
crf_default="$crf_input"
|
# Invert: user 51 (best) → ffmpeg 0, user 0 (worst) → ffmpeg 51
|
||||||
|
crf_ffmpeg_default=$(( 51 - crf_input ))
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -360,7 +339,7 @@ ask_video_quality() {
|
|||||||
local audio_input
|
local audio_input
|
||||||
audio_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \
|
audio_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \
|
||||||
"${audio_label} ${audio_range}:" \
|
"${audio_label} ${audio_range}:" \
|
||||||
"$audio_default") || quit
|
"$audio_default") || exit
|
||||||
|
|
||||||
# Empty → accept default
|
# Empty → accept default
|
||||||
if [[ -z "$audio_input" ]]; then
|
if [[ -z "$audio_input" ]]; then
|
||||||
@@ -377,10 +356,10 @@ ask_video_quality() {
|
|||||||
--msgbox "Please enter a valid integer."
|
--msgbox "Please enter a valid integer."
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "$crf_default $audio_default"
|
echo "$crf_ffmpeg_default $audio_default"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-media-type quality picker: image=0-100(default 85), video=CRF 0-51(default 23), audio=0-100(default 85)
|
# 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.
|
# But video→GIF/WebP uses 0-100 (same as images) since the output encoders use that scale.
|
||||||
AUDIO_QUALITY=""
|
AUDIO_QUALITY=""
|
||||||
case "$MEDIA_TYPE" in
|
case "$MEDIA_TYPE" in
|
||||||
@@ -399,13 +378,12 @@ case "$MEDIA_TYPE" in
|
|||||||
QUALITY=$(ask_quality image 50) # 0-100 scale, default 50
|
QUALITY=$(ask_quality image 50) # 0-100 scale, default 50
|
||||||
else
|
else
|
||||||
# Video → video: combined dialog for both video CRF and audio quality
|
# Video → video: combined dialog for both video CRF and audio quality
|
||||||
read -r VIDEO_QUALITY AUDIO_QUALITY <<< $(ask_video_quality "$TARGET")
|
read -r VIDEO_QUALITY AUDIO_QUALITY <<< "$(ask_video_quality "$TARGET")"
|
||||||
QUALITY="$VIDEO_QUALITY"
|
QUALITY="$VIDEO_QUALITY"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "Quality=$QUALITY"
|
echo "Quality=$QUALITY, AudioQuality=${AUDIO_QUALITY:-unset}"
|
||||||
echo "AudioQuality=$AUDIO_QUALITY"
|
|
||||||
|
|
||||||
# GIF & WebP get extra animation options
|
# GIF & WebP get extra animation options
|
||||||
if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then
|
if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then
|
||||||
@@ -420,7 +398,7 @@ if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then
|
|||||||
CHECKS=$(kdialog --title "Transmutate — Animation Settings" --checklist \
|
CHECKS=$(kdialog --title "Transmutate — Animation Settings" --checklist \
|
||||||
"Options for: $INPUT_BASENAME" \
|
"Options for: $INPUT_BASENAME" \
|
||||||
"1" "Animated" "on" \
|
"1" "Animated" "on" \
|
||||||
"2" "Looping" "off") || quit
|
"2" "Looping" "off") || exit
|
||||||
# Kdialog returns space-separated quoted IDs, e.g. "1" "2" or just "2"
|
# Kdialog returns space-separated quoted IDs, e.g. "1" "2" or just "2"
|
||||||
# Check if "2" (Looping) is in the selection
|
# Check if "2" (Looping) is in the selection
|
||||||
if [[ -n "$CHECKS" ]] && [[ "$CHECKS" == *"2"* ]]; then
|
if [[ -n "$CHECKS" ]] && [[ "$CHECKS" == *"2"* ]]; then
|
||||||
@@ -484,10 +462,11 @@ echo "Output: $OUTPUT_FILE"
|
|||||||
# ─── 8. Build ffmpeg command ────────────────────────────────────────────────────
|
# ─── 8. Build ffmpeg command ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# For images/audio: user-facing 0-100, ffmpeg q-scale is inverted (lower=better).
|
# 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.
|
# For video: user-facing CRF is 51-0 (higher=better), inverted to ffmpeg 0-51 (lower=better).
|
||||||
Q=""
|
Q=""
|
||||||
if [[ "$MEDIA_TYPE" == "video" ]]; then
|
if [[ "$MEDIA_TYPE" == "video" ]]; then
|
||||||
CRF="$QUALITY" # CRF used directly
|
# Already inverted in ask_video_quality or ask_quality, use directly
|
||||||
|
:
|
||||||
else
|
else
|
||||||
# Invert: user 100 (best) → ffmpeg q=1 (best), user 0 (worst) → ffmpeg q=100 (worst)
|
# Invert: user 100 (best) → ffmpeg q=1 (best), user 0 (worst) → ffmpeg q=100 (worst)
|
||||||
Q=$(( 100 - QUALITY ))
|
Q=$(( 100 - QUALITY ))
|
||||||
@@ -525,24 +504,40 @@ if [[ "$MEDIA_TYPE" == "video" && -n "$AUDIO_QUALITY" ]]; then
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build -map arguments for video streams
|
# ── Pre-compute subtitle codec argument ──────────────────────────
|
||||||
# -map 0:v:0 = always map first video stream
|
SQ_ARG=""
|
||||||
# -map 0:a:idx = map selected audio streams (stream-copy when no -c:a)
|
if (( ${#map_sub[@]} > 0 )); then
|
||||||
# -map 0:s:idx = map selected subtitle streams
|
case "$TARGET" in
|
||||||
# When AQ_ARG is set → add audio map + -c:a forces re-encode
|
mp4|mov)
|
||||||
# When AQ_ARG is empty → only add audio map for stream-copy
|
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"
|
MAP_ARGS="-map 0:v:0"
|
||||||
if (( ${#map_audio[@]} > 0 )); then
|
if (( ${#map_audio[@]} > 0 )); then
|
||||||
for ai in "${map_audio[@]}"; do
|
for ai in "${map_audio[@]}"; do
|
||||||
MAP_ARGS+=" -map 0:a:$ai"
|
MAP_ARGS+=" -map 0:$ai"
|
||||||
done
|
done
|
||||||
elif [[ -n "$AQ_ARG" ]]; then
|
elif [[ -n "$AQ_ARG" ]]; then
|
||||||
# Fallback if no audio selected but we need audio
|
# Fallback if no audio selected but we need audio
|
||||||
MAP_ARGS+=" -map 0:a:0"
|
MAP_ARGS+=" -map 0:a:0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if (( ${#map_sub[@]} > 0 )); then
|
if (( ${#map_sub[@]} > 0 )); then
|
||||||
for si in "${map_sub[@]}"; do
|
for si in "${map_sub[@]}"; do
|
||||||
MAP_ARGS+=" -map 0:s:$si"
|
MAP_ARGS+=" -map 0:$si"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -581,7 +576,6 @@ case "$MEDIA_TYPE" in
|
|||||||
# Map quality (0-100) to palette colors (3-256)
|
# Map quality (0-100) to palette colors (3-256)
|
||||||
# Minimum 3 because ffmpeg doesn't support max_colors=2 with reserve_transparent
|
# Minimum 3 because ffmpeg doesn't support max_colors=2 with reserve_transparent
|
||||||
IMG_PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 ))
|
IMG_PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 ))
|
||||||
echo "DEBUG: image→GIF fallback QUALITY=$QUALITY IMG_PALETTE_COLORS=$IMG_PALETTE_COLORS"
|
|
||||||
if $LOOPING; then
|
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}'"
|
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
|
else
|
||||||
@@ -623,23 +617,23 @@ case "$MEDIA_TYPE" in
|
|||||||
;;
|
;;
|
||||||
mp4)
|
mp4)
|
||||||
# Animated image → MP4: use libwebp/ffmpeg to extract frames, re-encode as h264
|
# 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}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
mkv)
|
mkv)
|
||||||
# Animated image → MKV
|
# Animated image → MKV
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
webm)
|
webm)
|
||||||
# Animated image → WebM
|
# Animated image → WebM
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
avi)
|
avi)
|
||||||
# Animated image → AVI
|
# Animated image → AVI
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
mov)
|
mov)
|
||||||
# Animated image → MOV
|
# Animated image → MOV
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
@@ -647,19 +641,19 @@ case "$MEDIA_TYPE" in
|
|||||||
video)
|
video)
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
mp4)
|
mp4)
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
mkv)
|
mkv)
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
webm)
|
webm)
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
avi)
|
avi)
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
mov)
|
mov)
|
||||||
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p '${OUTPUT_FILE}'"
|
||||||
;;
|
;;
|
||||||
gif)
|
gif)
|
||||||
# Video → GIF: use palettegen with quality-based dithering
|
# Video → GIF: use palettegen with quality-based dithering
|
||||||
@@ -670,12 +664,7 @@ case "$MEDIA_TYPE" in
|
|||||||
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_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")
|
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"
|
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"
|
FPS="$SRC_FPS"
|
||||||
else
|
|
||||||
FPS="$SRC_FPS"
|
|
||||||
fi
|
|
||||||
# Map quality (0-100) to palette generation colors (3-256)
|
# Map quality (0-100) to palette generation colors (3-256)
|
||||||
# Minimum 3 colors because ffmpeg doesn't support max_colors=2 with reserve_transparent
|
# Minimum 3 colors because ffmpeg doesn't support max_colors=2 with reserve_transparent
|
||||||
PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 ))
|
PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 ))
|
||||||
@@ -689,7 +678,6 @@ case "$MEDIA_TYPE" in
|
|||||||
else
|
else
|
||||||
DITHER="none"
|
DITHER="none"
|
||||||
fi
|
fi
|
||||||
echo "DEBUG: video→GIF QUALITY=$QUALITY PALETTE_COLORS=$PALETTE_COLORS DITHER=$DITHER"
|
|
||||||
if $LOOPING; then
|
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}'"
|
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
|
else
|
||||||
@@ -714,7 +702,7 @@ case "$MEDIA_TYPE" in
|
|||||||
if (( ${#map_audio[@]} > 0 )); then
|
if (( ${#map_audio[@]} > 0 )); then
|
||||||
AUDIO_MAP=""
|
AUDIO_MAP=""
|
||||||
for ai in "${map_audio[@]}"; do
|
for ai in "${map_audio[@]}"; do
|
||||||
AUDIO_MAP+="-map 0:a:$ai "
|
AUDIO_MAP+="-map 0:$ai "
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -755,10 +743,7 @@ esac
|
|||||||
# Skipped for WebP→GIF since ImageMagick handles those better.
|
# Skipped for WebP→GIF since ImageMagick handles those better.
|
||||||
|
|
||||||
USE_MAGICK=false
|
USE_MAGICK=false
|
||||||
if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "gif" ]] && has_magick; then
|
if [[ "$MEDIA_TYPE" == "image" ]] && { [[ "$TARGET" == "gif" ]] || [[ "$TARGET" == "webp" ]]; } && has_magick; then
|
||||||
USE_MAGICK=true
|
|
||||||
fi
|
|
||||||
if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "webp" ]] && has_magick; then
|
|
||||||
USE_MAGICK=true
|
USE_MAGICK=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
customtkinter
|
||||||
|
Pillow
|
||||||
|
ffmpeg-python
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Test package for transmutate_app."""
|
||||||
113
tests/test_engine.py
Normal file
113
tests/test_engine.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for the FFmpeg engine module."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Ensure the rewrite directory is on sys.path
|
||||||
|
rewrite_dir = os.path.join(os.path.dirname(__file__), '..')
|
||||||
|
if rewrite_dir not in sys.path:
|
||||||
|
sys.path.insert(0, rewrite_dir)
|
||||||
|
|
||||||
|
from transmutate_app.engine.ffmpeg_engine import (
|
||||||
|
StreamInfo,
|
||||||
|
ProbeResult,
|
||||||
|
_parse_time_to_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamInfo(unittest.TestCase):
|
||||||
|
"""Tests for the StreamInfo dataclass and label method."""
|
||||||
|
|
||||||
|
def test_label_with_language(self):
|
||||||
|
stream = StreamInfo(
|
||||||
|
index=1,
|
||||||
|
codec_type="audio",
|
||||||
|
codec_name="aac",
|
||||||
|
language="eng",
|
||||||
|
)
|
||||||
|
self.assertEqual(stream.label(), "Audio #1 — aac (eng)")
|
||||||
|
|
||||||
|
def test_label_without_language(self):
|
||||||
|
stream = StreamInfo(
|
||||||
|
index=0,
|
||||||
|
codec_type="video",
|
||||||
|
codec_name="h264",
|
||||||
|
language=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(stream.label(), "Video #0 — h264")
|
||||||
|
|
||||||
|
def test_label_with_empty_string_language(self):
|
||||||
|
stream = StreamInfo(
|
||||||
|
index=2,
|
||||||
|
codec_type="subtitle",
|
||||||
|
codec_name="mov_text",
|
||||||
|
language="",
|
||||||
|
)
|
||||||
|
# Empty string language should be treated as None
|
||||||
|
self.assertEqual(stream.label(), "Subtitle #2 — mov_text")
|
||||||
|
|
||||||
|
def test_label_defaults(self):
|
||||||
|
stream = StreamInfo(index=3, codec_type="data", codec_name="unknown")
|
||||||
|
self.assertEqual(stream.label(), "Data #3 — unknown")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeResult(unittest.TestCase):
|
||||||
|
"""Tests for the ProbeResult dataclass defaults."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
result = ProbeResult()
|
||||||
|
self.assertEqual(result.streams, [])
|
||||||
|
self.assertFalse(result.is_animated)
|
||||||
|
self.assertEqual(result.mime_type, "")
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
stream = StreamInfo(0, "audio", "aac", "eng")
|
||||||
|
result = ProbeResult(
|
||||||
|
streams=[stream],
|
||||||
|
is_animated=True,
|
||||||
|
mime_type="video/mp4",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.streams, [stream])
|
||||||
|
self.assertTrue(result.is_animated)
|
||||||
|
self.assertEqual(result.mime_type, "video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTime(unittest.TestCase):
|
||||||
|
"""Tests for the time parser used in progress reporting."""
|
||||||
|
|
||||||
|
def test_hh_mm_ss(self):
|
||||||
|
result = _parse_time_to_seconds("01:23:45.67")
|
||||||
|
self.assertAlmostEqual(result, 5025.67, places=2)
|
||||||
|
|
||||||
|
def test_hh_mm_ss_int(self):
|
||||||
|
result = _parse_time_to_seconds("00:00:01")
|
||||||
|
self.assertAlmostEqual(result, 1.0, places=2)
|
||||||
|
|
||||||
|
def test_mm_ss(self):
|
||||||
|
result = _parse_time_to_seconds("01:23.45")
|
||||||
|
self.assertAlmostEqual(result, 83.45, places=2)
|
||||||
|
|
||||||
|
def test_ss_only(self):
|
||||||
|
result = _parse_time_to_seconds("12.34")
|
||||||
|
self.assertAlmostEqual(result, 12.34, places=2)
|
||||||
|
|
||||||
|
def test_integer_seconds(self):
|
||||||
|
result = _parse_time_to_seconds("42")
|
||||||
|
self.assertAlmostEqual(result, 42.0, places=2)
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
result = _parse_time_to_seconds("0")
|
||||||
|
self.assertAlmostEqual(result, 0.0, places=2)
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
result = _parse_time_to_seconds("invalid")
|
||||||
|
self.assertAlmostEqual(result, 0.0, places=2)
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
result = _parse_time_to_seconds("")
|
||||||
|
self.assertAlmostEqual(result, 0.0, places=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
31
transmutate.py
Normal file
31
transmutate.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Transmutate — Media Conversion GUI.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python transmutate.py <path/to/image|video|audio>
|
||||||
|
|
||||||
|
Opens a CustomTkinter GUI for converting the specified file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from transmutate_app.gui import open_file
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point: validate arguments and open the GUI."""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python transmutate.py <path/to/image|video|audio>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filepath = os.path.abspath(sys.argv[1])
|
||||||
|
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
print(f"Error: File does not exist: {filepath}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
open_file(filepath)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
transmutate_app/__init__.py
Normal file
1
transmutate_app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""transmutate_app — Media Conversion GUI package."""
|
||||||
19
transmutate_app/__main__.py
Normal file
19
transmutate_app/__main__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Allow running transmutate_app as a module: python -m transmutate_app <file>."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from .gui import open_file
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point for ``python -m transmutate_app <file>``."""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python -m transmutate_app <path/to/file>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filepath = sys.argv[1]
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
print(f"Error: File does not exist: {filepath}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
open_file(filepath)
|
||||||
1
transmutate_app/engine/__init__.py
Normal file
1
transmutate_app/engine/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Engine module — FFmpeg and ImageMagick command builders."""
|
||||||
728
transmutate_app/engine/ffmpeg_engine.py
Normal file
728
transmutate_app/engine/ffmpeg_engine.py
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
"""FFmpeg engine — command builders, probing, and execution for Transmutate."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamInfo:
|
||||||
|
"""Metadata for a single media stream."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
codec_type: str
|
||||||
|
codec_name: str
|
||||||
|
language: Optional[str] = None
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
"""Return a human-readable label for this stream."""
|
||||||
|
part = f"#{self.index} \u2014 {self.codec_name}"
|
||||||
|
lang = self.language if self.language else None
|
||||||
|
if lang:
|
||||||
|
part += f" ({lang})"
|
||||||
|
return f"{self.codec_type.capitalize()} {part}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProbeResult:
|
||||||
|
"""Result of probing a media file with ffprobe."""
|
||||||
|
|
||||||
|
streams: list[StreamInfo] = field(default_factory=list)
|
||||||
|
is_animated: bool = False
|
||||||
|
mime_type: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper — _parse_time_to_seconds (used in progress reporting)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time_to_seconds(t: str) -> float:
|
||||||
|
"""Parse an ``HH:MM:SS.frac`` / ``MM:SS.frac`` / ``SS.frac`` / ``SS``
|
||||||
|
timecode to a float of seconds. Returns 0.0 on failure."""
|
||||||
|
if not t:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
parts = t.split(":")
|
||||||
|
if len(parts) == 3:
|
||||||
|
h, m, s = parts
|
||||||
|
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||||
|
elif len(parts) == 2:
|
||||||
|
m, s = parts
|
||||||
|
return int(m) * 60 + float(s)
|
||||||
|
else:
|
||||||
|
return float(parts[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FFmpeg / ffmpeg-python availability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ffmpeg = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_ffmpeg = import_module("ffmpeg")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def has_ffmpeg() -> bool:
|
||||||
|
"""Return ``True`` when the ``ffmpeg`` binary is available in PATH."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-version"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MIME detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_magic_mime(filepath: str) -> str:
|
||||||
|
"""Attempt to get the MIME type via ``file --mime-type`` / ``file -b``.
|
||||||
|
|
||||||
|
Falls back to extension-based detection.
|
||||||
|
"""
|
||||||
|
# Try Python ``filetype`` / ``mimetypes`` first
|
||||||
|
try:
|
||||||
|
import filetype # type: ignore[import-not-found]
|
||||||
|
kind = filetype.guess(filepath)
|
||||||
|
if kind is not None and kind.mime:
|
||||||
|
return kind.mime
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try ``file --mime-type``
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["file", "--mime-type", "-b", filepath],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: extension-based
|
||||||
|
ext = Path(filepath).suffix.lstrip(".").lower()
|
||||||
|
_EXT_MIME = {
|
||||||
|
"png": "image/png",
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"bmp": "image/bmp",
|
||||||
|
"tiff": "image/tiff",
|
||||||
|
"tif": "image/tiff",
|
||||||
|
"avif": "image/avif",
|
||||||
|
"heic": "image/heic",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"mkv": "video/x-matroska",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"avi": "video/x-msvideo",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
"flv": "video/x-flv",
|
||||||
|
"wmv": "video/x-ms-wmv",
|
||||||
|
"m4v": "video/x-m4v",
|
||||||
|
"mpg": "video/mpeg",
|
||||||
|
"mpeg": "video/mpeg",
|
||||||
|
"3gp": "video/3gpp",
|
||||||
|
"ts": "video/mp2t",
|
||||||
|
"ogv": "video/ogg",
|
||||||
|
"m2ts": "video/MP2T",
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"flac": "audio/flac",
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"ogg": "audio/ogg",
|
||||||
|
"m4a": "audio/mp4",
|
||||||
|
"aac": "audio/aac",
|
||||||
|
"opus": "audio/opus",
|
||||||
|
"wma": "audio/x-ms-wma",
|
||||||
|
"aiff": "audio/aiff",
|
||||||
|
"ape": "audio/ape",
|
||||||
|
"alac": "audio/x-alac",
|
||||||
|
}
|
||||||
|
return _EXT_MIME.get(ext, "")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_mime(filepath: str) -> str:
|
||||||
|
"""Detect the MIME type of *filepath*."""
|
||||||
|
mime = _get_magic_mime(filepath)
|
||||||
|
if mime:
|
||||||
|
return mime
|
||||||
|
# Also try ffprobe as fallback
|
||||||
|
probe_result = _probe_json(filepath)
|
||||||
|
if probe_result:
|
||||||
|
fmt = probe_result.get("format", {})
|
||||||
|
for k in ("mime_type", "format_name"):
|
||||||
|
v = fmt.get(k)
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def detect_media_type(mime: str) -> str:
|
||||||
|
"""Return ``image``, ``video``, or ``audio`` based on MIME, or empty."""
|
||||||
|
if not mime:
|
||||||
|
return ""
|
||||||
|
if mime.startswith("image/"):
|
||||||
|
return "image"
|
||||||
|
if mime.startswith("video/"):
|
||||||
|
return "video"
|
||||||
|
if mime.startswith("audio/"):
|
||||||
|
return "audio"
|
||||||
|
# Map some common format names
|
||||||
|
name_map = {
|
||||||
|
"image/png": "image",
|
||||||
|
"image/jpeg": "image",
|
||||||
|
"image/gif": "image",
|
||||||
|
"image/webp": "image",
|
||||||
|
"image/avif": "image",
|
||||||
|
"image/bmp": "image",
|
||||||
|
"image/tiff": "image",
|
||||||
|
"video/mp4": "video",
|
||||||
|
"video/x-matroska": "video",
|
||||||
|
"video/webm": "video",
|
||||||
|
"video/x-msvideo": "video",
|
||||||
|
"video/quicktime": "video",
|
||||||
|
"video/x-flv": "video",
|
||||||
|
"video/x-ms-wmv": "video",
|
||||||
|
"video/mpeg": "video",
|
||||||
|
"video/3gpp": "video",
|
||||||
|
"video/ogg": "video",
|
||||||
|
"audio/mpeg": "audio",
|
||||||
|
"audio/flac": "audio",
|
||||||
|
"audio/wav": "audio",
|
||||||
|
"audio/ogg": "audio",
|
||||||
|
"audio/mp4": "audio",
|
||||||
|
"audio/aac": "audio",
|
||||||
|
"audio/opus": "audio",
|
||||||
|
}
|
||||||
|
return name_map.get(mime, "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Probing — ffprobe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_json(filepath: str, timeout: int = 10) -> Optional[dict]:
|
||||||
|
"""Run ffprobe via ffmpeg-python and return parsed JSON, or None on failure.
|
||||||
|
|
||||||
|
Falls back to a raw subprocess call if ffmpeg-python fails.
|
||||||
|
"""
|
||||||
|
# Try ffmpeg-python first
|
||||||
|
if _ffmpeg is not None:
|
||||||
|
try:
|
||||||
|
metadata: dict = _ffmpeg.probe(filepath) # type: ignore[attr-defined]
|
||||||
|
if isinstance(metadata, dict) and metadata.get("streams"):
|
||||||
|
return metadata
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: use ffprobe directly via subprocess
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ffprobe", "-v", "quiet", "-show_format", "-show_streams",
|
||||||
|
"-of", "json", filepath],
|
||||||
|
capture_output=True, text=False, timeout=timeout,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return json.loads(result.stdout.decode("utf-8"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_animated_image_mime(filepath: str) -> Optional[str]:
|
||||||
|
"""Attempt to detect if an image is animated using Pillow, and return its MIME."""
|
||||||
|
try:
|
||||||
|
from PIL import Image # type: ignore[import-not-found]
|
||||||
|
img = Image.open(filepath)
|
||||||
|
# Animated images have multiple frames (GIF, WebP)
|
||||||
|
if hasattr(img, "n_frames") and int(getattr(img, "n_frames", 0)) > 1:
|
||||||
|
ext = Path(filepath).suffix.lower()
|
||||||
|
return {
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}.get(ext)
|
||||||
|
if hasattr(img, "is_animated") and img.is_animated:
|
||||||
|
ext = Path(filepath).suffix.lower()
|
||||||
|
return {
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}.get(ext)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def probe_file(filepath: str, mime: str = "") -> ProbeResult:
|
||||||
|
"""Probe *filepath* with ffprobe and return a :class:`ProbeResult`.
|
||||||
|
|
||||||
|
Handles both media files and animated images (GIF/WebP).
|
||||||
|
"""
|
||||||
|
result = ProbeResult()
|
||||||
|
result.mime_type = mime
|
||||||
|
|
||||||
|
# Special case: animated images
|
||||||
|
if mime in ("image/gif", "image/webp"):
|
||||||
|
animated_mime = _get_animated_image_mime(filepath)
|
||||||
|
if animated_mime:
|
||||||
|
result.is_animated = True
|
||||||
|
result.mime_type = animated_mime
|
||||||
|
|
||||||
|
data = _probe_json(filepath)
|
||||||
|
if not data:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Parse streams
|
||||||
|
for s in data.get("streams", []):
|
||||||
|
stype = s.get("codec_type", "unknown")
|
||||||
|
cname = s.get("codec_name", "unknown")
|
||||||
|
lang = s.get("tags", {}).get("language", None)
|
||||||
|
if lang == "und":
|
||||||
|
lang = None
|
||||||
|
idx = s.get("index", 0)
|
||||||
|
result.streams.append(StreamInfo(
|
||||||
|
index=idx,
|
||||||
|
codec_type=stype,
|
||||||
|
codec_name=cname,
|
||||||
|
language=lang,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preflight — make sure ffmpeg can read the source file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_check(filepath: str) -> tuple[bool, str]:
|
||||||
|
"""Verify that *filepath* is readable by ffmpeg.
|
||||||
|
|
||||||
|
Returns ``(ok, error_message)``.
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
return False, f"File does not exist: {filepath}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-show_format", "-show_streams",
|
||||||
|
filepath],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, f"ffprobe failed: {result.stderr.strip()}"
|
||||||
|
return True, ""
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "ffprobe timed out"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"ffprobe error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Execution — run_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(cmd: list[str]) -> tuple[bool, str]:
|
||||||
|
"""Execute *cmd* (a list of strings) via subprocess.
|
||||||
|
|
||||||
|
Returns ``(success, message)``. *message* is empty on success.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3600, # generous 1-hour timeout
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
stderr = proc.stderr.strip()
|
||||||
|
# Summarise the last few lines of ffmpeg output for the user
|
||||||
|
lines = stderr.split("\n")
|
||||||
|
summary = "\n".join(lines[-10:]) if len(lines) > 10 else stderr
|
||||||
|
return False, summary
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Conversion timed out (exceeded 1 hour)"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Quality helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_to_crf(quality: int) -> int:
|
||||||
|
"""Map user quality (0–100) to a CRF value (0–51).
|
||||||
|
|
||||||
|
Higher quality → lower CRF.
|
||||||
|
"""
|
||||||
|
# Invert so quality=100 → CRF=0, quality=0 → CRF=51
|
||||||
|
return max(0, min(51, 51 - int(quality * 51 / 100)))
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_quality_to_abr(codec: str, quality: int) -> tuple[str, str]:
|
||||||
|
"""Return (param_flag, value) for audio quality.
|
||||||
|
|
||||||
|
Supports AAC, MP3, Opus, FLAC, Vorbis.
|
||||||
|
"""
|
||||||
|
quality = max(0, min(100, quality))
|
||||||
|
|
||||||
|
if codec == "aac":
|
||||||
|
# AAC uses -b:a with a rate mapping
|
||||||
|
# Map 0-100 → 32-320 kbps
|
||||||
|
abr = max(32, int(32 + quality * 288 / 100))
|
||||||
|
return ("-b:a", f"{abr}k")
|
||||||
|
elif codec == "libmp3lame":
|
||||||
|
# MP3 uses -q:a (VBR) 0-9 → map quality
|
||||||
|
q = max(0, min(9, int(9 * (100 - quality) / 100)))
|
||||||
|
return ("-q:a", str(q))
|
||||||
|
elif codec == "libvorbis":
|
||||||
|
# Vorbis uses -q:a 0-10 → map quality
|
||||||
|
q = max(0, min(10, round(quality * 10 / 100)))
|
||||||
|
return ("-q:a", str(q))
|
||||||
|
elif codec == "libopus":
|
||||||
|
# Opus uses -b:a
|
||||||
|
abr = max(32, int(32 + quality * 256 / 100))
|
||||||
|
return ("-b:a", f"{abr}k")
|
||||||
|
elif codec == "flac":
|
||||||
|
# FLAC is lossless — quality is irrelevant, just use default
|
||||||
|
return ("-compression_level", "5")
|
||||||
|
elif codec == "pcm_s16le":
|
||||||
|
# WAV is lossless
|
||||||
|
return ("", "")
|
||||||
|
else:
|
||||||
|
# Generic: use bitrate
|
||||||
|
abr = max(32, int(32 + quality * 288 / 100))
|
||||||
|
return ("-b:a", f"{abr}k")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image → Image command builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_image_command(
|
||||||
|
src: str,
|
||||||
|
dst: str,
|
||||||
|
fmt: str,
|
||||||
|
quality: int,
|
||||||
|
is_animated: bool,
|
||||||
|
loop: bool,
|
||||||
|
mime: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build an ffmpeg command for image → image conversion.
|
||||||
|
|
||||||
|
Handles both single-frame and animated images.
|
||||||
|
"""
|
||||||
|
ext = fmt.lower()
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", src]
|
||||||
|
|
||||||
|
if is_animated:
|
||||||
|
# Animated: preserve all frames
|
||||||
|
cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
|
||||||
|
if ext == "png":
|
||||||
|
# For animated PNG, extract frames then use ImageMagick or ffmpeg
|
||||||
|
# We'll use ffmpeg with a numbered sequence
|
||||||
|
pass
|
||||||
|
elif ext == "webp":
|
||||||
|
cmd.extend(["-c:v", "libwebp_anim", "-loop", "0"])
|
||||||
|
elif ext == "gif":
|
||||||
|
# Palettegen-based GIF
|
||||||
|
return _build_video_gif(src, dst, quality)
|
||||||
|
elif ext in ("mp4", "mkv", "webm", "avi", "mov"):
|
||||||
|
cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
|
||||||
|
if ext == "webm":
|
||||||
|
cmd.extend(["-c:a", "libvorbis"])
|
||||||
|
if loop and ext in ("webp", "gif"):
|
||||||
|
cmd.extend(["-loop", "0"])
|
||||||
|
else:
|
||||||
|
# Single frame — just encode it
|
||||||
|
cmd.extend(["-frames:v", "1"])
|
||||||
|
if ext == "png":
|
||||||
|
cmd.extend(["-c:v", "png"])
|
||||||
|
elif ext in ("jpg", "jpeg"):
|
||||||
|
q = _quality_to_crf(quality)
|
||||||
|
cmd.extend(["-c:v", "libjpeg-turbo", "-q:v", str(q)])
|
||||||
|
elif ext == "webp":
|
||||||
|
lossless = quality >= 100
|
||||||
|
if lossless:
|
||||||
|
cmd.extend(["-c:v", "libwebp", "-lossless", "1"])
|
||||||
|
else:
|
||||||
|
q = _quality_to_crf(quality)
|
||||||
|
cmd.extend(["-c:v", "libwebp", "-lossless", "0", "-q:v", str(q)])
|
||||||
|
elif ext == "avif":
|
||||||
|
q = _quality_to_crf(quality)
|
||||||
|
cmd.extend(["-c:v", "libaom-av1", "-cpu-used", "4", "-q:v", str(q)])
|
||||||
|
elif ext == "bmp":
|
||||||
|
cmd.extend(["-c:v", "bmp"])
|
||||||
|
|
||||||
|
cmd.extend(["-pix_fmt", "yuv420p", dst])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Video → Video command builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_video_command(
|
||||||
|
src: str,
|
||||||
|
dst: str,
|
||||||
|
fmt: str,
|
||||||
|
quality: int,
|
||||||
|
audio_quality: int,
|
||||||
|
audio_streams: list[int],
|
||||||
|
sub_streams: list[int],
|
||||||
|
mime: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build an ffmpeg command for video → video conversion.
|
||||||
|
|
||||||
|
Handles audio stream selection and subtitle streams.
|
||||||
|
"""
|
||||||
|
cmd = ["ffmpeg", "-y"]
|
||||||
|
|
||||||
|
# Input
|
||||||
|
cmd.extend(["-i", src])
|
||||||
|
|
||||||
|
# Select audio streams if specified
|
||||||
|
# audio_streams contains ffprobe stream indices (global), e.g. [1]
|
||||||
|
# Use the global-index form ``0:<index>`` — not ``0:a:N`` (invalid syntax).
|
||||||
|
if audio_streams:
|
||||||
|
for idx in audio_streams:
|
||||||
|
cmd.extend(["-map", f"0:{idx}"])
|
||||||
|
else:
|
||||||
|
# No specific audio selection — copy first audio stream if present
|
||||||
|
cmd.extend(["-map", "0:a:0"])
|
||||||
|
|
||||||
|
# Subtitles
|
||||||
|
# sub_streams contains ffprobe stream indices (global), e.g. [2]
|
||||||
|
if sub_streams:
|
||||||
|
for idx in sub_streams:
|
||||||
|
cmd.extend(["-map", f"0:{idx}"])
|
||||||
|
else:
|
||||||
|
# Try to copy subtitle streams
|
||||||
|
cmd.extend(["-map", "0:s?"])
|
||||||
|
|
||||||
|
# Video encoding
|
||||||
|
cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
|
||||||
|
|
||||||
|
# CRF mode
|
||||||
|
crf = _quality_to_crf(quality)
|
||||||
|
cmd.extend(["-crf", str(crf)])
|
||||||
|
|
||||||
|
# Audio encoding
|
||||||
|
ext = fmt.lower()
|
||||||
|
audio_codec_map = {
|
||||||
|
"mp4": "aac",
|
||||||
|
"mkv": "aac",
|
||||||
|
"webm": "libvorbis",
|
||||||
|
"avi": "aac",
|
||||||
|
"mov": "aac",
|
||||||
|
}
|
||||||
|
ac = audio_codec_map.get(ext, "aac")
|
||||||
|
|
||||||
|
af, av = _audio_quality_to_abr(ac, audio_quality)
|
||||||
|
if af:
|
||||||
|
cmd.extend([af, av])
|
||||||
|
|
||||||
|
# Container-specific options
|
||||||
|
if ext == "webm":
|
||||||
|
cmd.extend(["-c:a", "libvorbis"])
|
||||||
|
elif ext == "mp4":
|
||||||
|
cmd.extend(["-movflags", "+faststart"])
|
||||||
|
|
||||||
|
cmd.extend(["-c:s", "mov_text"])
|
||||||
|
|
||||||
|
# Output
|
||||||
|
cmd.append(dst)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Video → Audio command builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_audio_command(
|
||||||
|
src: str,
|
||||||
|
dst: str,
|
||||||
|
fmt: str,
|
||||||
|
quality: int,
|
||||||
|
audio_streams: list[int],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build an ffmpeg command for audio extraction / format conversion.
|
||||||
|
|
||||||
|
Can be called from video→audio or audio→audio conversions.
|
||||||
|
"""
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", src]
|
||||||
|
|
||||||
|
# Select audio stream(s)
|
||||||
|
# audio_streams contains ffprobe stream indices (global), e.g. [1]
|
||||||
|
# We must use the global-index form ``0:<index>`` — not ``0:a:N``
|
||||||
|
# which is invalid ffmpeg syntax.
|
||||||
|
if audio_streams:
|
||||||
|
for idx in audio_streams:
|
||||||
|
cmd.extend(["-map", f"0:{idx}"])
|
||||||
|
else:
|
||||||
|
cmd.extend(["-map", "0:a:0"])
|
||||||
|
|
||||||
|
# Discard video — keep audio only
|
||||||
|
cmd.extend(["-vn"])
|
||||||
|
|
||||||
|
# Determine codec from format
|
||||||
|
fmt_lower = fmt.lower()
|
||||||
|
_codec_defaults: dict[str, tuple[str, str, str]] = {
|
||||||
|
"mp3": ("libmp3lame", "", ""),
|
||||||
|
"flac": ("flac", "-compression_level", "5"),
|
||||||
|
"wav": ("pcm_s16le", "", ""),
|
||||||
|
"ogg": ("libvorbis", "", ""),
|
||||||
|
"m4a": ("aac", "", ""),
|
||||||
|
"aac": ("aac", "", ""),
|
||||||
|
}
|
||||||
|
ac, af1, af2 = _codec_defaults.get(fmt_lower, ("aac", "-b:a", "128k"))
|
||||||
|
|
||||||
|
cmd.extend(["-c:a", ac])
|
||||||
|
if af1:
|
||||||
|
cmd.extend([af1])
|
||||||
|
if af2:
|
||||||
|
cmd.extend([af2])
|
||||||
|
|
||||||
|
# Quality override
|
||||||
|
qf, qv = _audio_quality_to_abr(ac, quality)
|
||||||
|
if qf:
|
||||||
|
# Replace the default codec params with quality-based ones
|
||||||
|
cmd = cmd[:-2] if len(cmd) >= 2 and cmd[-2] == af1 else cmd
|
||||||
|
cmd.extend([qf, qv])
|
||||||
|
|
||||||
|
# Output
|
||||||
|
cmd.append(dst)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Video → GIF (palettegen-based)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_video_gif(src: str, dst: str, quality: int) -> list[str]:
|
||||||
|
"""Build a palettegen-based ffmpeg command for video → GIF.
|
||||||
|
|
||||||
|
Uses a two-pass approach: first generate a palette, then use it.
|
||||||
|
"""
|
||||||
|
quality = max(0, min(100, quality))
|
||||||
|
|
||||||
|
# Map quality to dithering mode and palette quality
|
||||||
|
# Higher quality = better palette + more dithering
|
||||||
|
if quality > 80:
|
||||||
|
dither = "bayer"
|
||||||
|
bayer_scale = "5"
|
||||||
|
elif quality > 60:
|
||||||
|
dither = "sierra2_4a"
|
||||||
|
bayer_scale = "4"
|
||||||
|
elif quality > 40:
|
||||||
|
dither = "burkes"
|
||||||
|
bayer_scale = "3"
|
||||||
|
else:
|
||||||
|
dither = "none"
|
||||||
|
bayer_scale = "1"
|
||||||
|
|
||||||
|
# Palette generation
|
||||||
|
palette_temp = dst + ".palette"
|
||||||
|
|
||||||
|
# Two-pass command:
|
||||||
|
# Pass 1: generate palette
|
||||||
|
# Pass 2: use palette for GIF
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", src,
|
||||||
|
"-vf",
|
||||||
|
"fps=24,scale=640:-1:flags=lanczos,palettegen=max_colors=256:stats_mode=diff",
|
||||||
|
"-y", palette_temp,
|
||||||
|
]
|
||||||
|
# Run pass 1 separately, then append pass 2
|
||||||
|
subprocess.run(cmd, capture_output=True, timeout=300)
|
||||||
|
|
||||||
|
# Pass 2: apply palette
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", src,
|
||||||
|
"-i", palette_temp,
|
||||||
|
"-lavfi",
|
||||||
|
f"fps=24,scale=640:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither={dither}:bayer_scale={bayer_scale}:diff_mode=rectangle",
|
||||||
|
dst,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cleanup palette temp file
|
||||||
|
try:
|
||||||
|
os.remove(palette_temp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Video → Animated WebP (libwebp_anim)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_video_webp(
|
||||||
|
src: str,
|
||||||
|
dst: str,
|
||||||
|
quality: int,
|
||||||
|
loop: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build an ffmpeg command for video → animated WebP using libwebp_anim."""
|
||||||
|
quality = max(0, min(100, quality))
|
||||||
|
|
||||||
|
# WebP uses lossless/lossy quality
|
||||||
|
# Map quality: 0-100 → lossless or quality factor 0-100
|
||||||
|
lossiness = max(0, int((100 - quality) * 10))
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", src,
|
||||||
|
"-c:v", "libwebp_anim",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-lossless", "0",
|
||||||
|
"-lossiness", str(lossiness),
|
||||||
|
]
|
||||||
|
|
||||||
|
if loop:
|
||||||
|
cmd.extend(["-loop", "0"])
|
||||||
|
|
||||||
|
# Frame rate
|
||||||
|
cmd.extend(["-r", "24"])
|
||||||
|
|
||||||
|
# Output
|
||||||
|
cmd.append(dst)
|
||||||
|
return cmd
|
||||||
969
transmutate_app/gui.py
Normal file
969
transmutate_app/gui.py
Normal file
@@ -0,0 +1,969 @@
|
|||||||
|
"""Main GUI module for Transmutate — CustomTkinter application."""
|
||||||
|
|
||||||
|
# flake8: noqa: F401 (imports used by TransmutateApp, not exposed at module level)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from .engine.ffmpeg_engine import (
|
||||||
|
detect_mime,
|
||||||
|
detect_media_type,
|
||||||
|
probe_file,
|
||||||
|
has_ffmpeg,
|
||||||
|
preflight_check,
|
||||||
|
run_command,
|
||||||
|
_build_image_command,
|
||||||
|
_build_video_command,
|
||||||
|
_build_audio_command,
|
||||||
|
_build_video_gif,
|
||||||
|
_build_video_webp,
|
||||||
|
StreamInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Supported output formats
|
||||||
|
IMAGE_FORMATS = {
|
||||||
|
"png": "PNG",
|
||||||
|
"jpg": "JPG",
|
||||||
|
"webp": "WebP",
|
||||||
|
"avif": "AVIF",
|
||||||
|
}
|
||||||
|
|
||||||
|
ANIMATED_IMAGE_FORMATS = {
|
||||||
|
**IMAGE_FORMATS,
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4": "MP4",
|
||||||
|
"mkv": "MKV",
|
||||||
|
"webm": "WebM",
|
||||||
|
"avi": "AVI",
|
||||||
|
"mov": "MOV",
|
||||||
|
}
|
||||||
|
|
||||||
|
VIDEO_FORMATS = {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"mkv": "MKV",
|
||||||
|
"webm": "WebM",
|
||||||
|
"avi": "AVI",
|
||||||
|
"mov": "MOV",
|
||||||
|
"gif": "GIF",
|
||||||
|
"webp": "WebP (animated)",
|
||||||
|
}
|
||||||
|
|
||||||
|
AUDIO_FORMATS = {
|
||||||
|
"mp3": "MP3",
|
||||||
|
"flac": "FLAC",
|
||||||
|
"wav": "WAV",
|
||||||
|
"ogg": "OGG",
|
||||||
|
"m4a": "M4A",
|
||||||
|
"aac": "AAC",
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConversionConfig:
|
||||||
|
"""Dataclass-like holder for all user-selected conversion parameters."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.source_file: str = ""
|
||||||
|
self.media_type: str = "" # image, video, audio
|
||||||
|
self.mime_type: str = ""
|
||||||
|
self.is_animated: bool = False
|
||||||
|
self.target_format: str = "" # png, mp4, mp3, etc.
|
||||||
|
self.audio_streams: list[int] = [] # ffprobe stream indices
|
||||||
|
self.sub_streams: list[int] = [] # ffprobe stream indices
|
||||||
|
self.quality: int = 85 # user-facing 0-100
|
||||||
|
self.audio_quality: int = 85 # per-output-type scale
|
||||||
|
self.loop: bool = False
|
||||||
|
self.output_file: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CustomTkinter Dialog Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _die(root: ctk.CTk, message: str) -> None:
|
||||||
|
"""Show an error dialog, then destroy the root window and exit."""
|
||||||
|
_show_message(root, "Transmutate — Error", message, kind="error")
|
||||||
|
root.destroy()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _show_message(
|
||||||
|
parent: ctk.CTk, title: str, message: str,
|
||||||
|
kind: str = "info",
|
||||||
|
) -> None:
|
||||||
|
"""Show a CustomTkinter modal dialog with a single OK button."""
|
||||||
|
dialog = ctk.CTkToplevel(parent)
|
||||||
|
dialog.title(title)
|
||||||
|
dialog.resizable(False, False)
|
||||||
|
|
||||||
|
# Center the dialog
|
||||||
|
parent.update_idletasks()
|
||||||
|
pw, ph = parent.winfo_width(), parent.winfo_height()
|
||||||
|
px, py = parent.winfo_x(), parent.winfo_y()
|
||||||
|
dw, dh = 420, 240
|
||||||
|
dx = px + (pw - dw) // 2
|
||||||
|
dy = py + (ph - dh) // 2
|
||||||
|
dialog.geometry(f"{dw}x{dh}+{dx}+{dy}")
|
||||||
|
|
||||||
|
# Icon based on kind
|
||||||
|
icons = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "question": "❓"}
|
||||||
|
icon = icons.get(kind, "ℹ️")
|
||||||
|
|
||||||
|
# Theme based on kind
|
||||||
|
themes = {
|
||||||
|
"info": ("#0ea5e9", "#0284c7", "#0369a1"),
|
||||||
|
"warning": ("#f59e0b", "#d97706", "#b45309"),
|
||||||
|
"error": ("#ef4444", "#dc2626", "#b91c1c"),
|
||||||
|
"question":("#8b5cf6", "#7c3aed", "#6d28d9"),
|
||||||
|
}
|
||||||
|
bg_color, btn_color, btn_hover = themes.get(kind, themes["info"])
|
||||||
|
|
||||||
|
body = ctk.CTkFrame(dialog, fg_color="transparent")
|
||||||
|
body.pack(fill="both", padx=20, pady=16)
|
||||||
|
|
||||||
|
icon_lbl = ctk.CTkLabel(
|
||||||
|
body, text=icon, font=ctk.CTkFont(size=20),
|
||||||
|
fg_color="transparent", text_color=bg_color,
|
||||||
|
)
|
||||||
|
icon_lbl.pack()
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
body, text=message, wraplength=360,
|
||||||
|
font=ctk.CTkFont(size=13), justify="center",
|
||||||
|
).pack(pady=(8, 8))
|
||||||
|
|
||||||
|
btn_frame = ctk.CTkFrame(body, fg_color="transparent")
|
||||||
|
btn_frame.pack(pady=(0, 8))
|
||||||
|
|
||||||
|
def on_close():
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
close_btn = ctk.CTkButton(
|
||||||
|
btn_frame, text="OK", width=80,
|
||||||
|
fg_color=btn_color,
|
||||||
|
hover_color=btn_hover,
|
||||||
|
text_color="#ffffff",
|
||||||
|
font=ctk.CTkFont(size=13, weight="bold"),
|
||||||
|
corner_radius=8,
|
||||||
|
command=on_close,
|
||||||
|
)
|
||||||
|
close_btn.pack()
|
||||||
|
|
||||||
|
# Must be visible before grab_set so grab doesn't fail
|
||||||
|
dialog.update_idletasks()
|
||||||
|
dialog.grab_set()
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_question(
|
||||||
|
parent: ctk.CTk, title: str, message: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Show a modal dialog asking the user to overwrite or rename.
|
||||||
|
|
||||||
|
Returns ``True`` for overwrite, ``False`` for rename.
|
||||||
|
"""
|
||||||
|
dialog = ctk.CTkToplevel(parent)
|
||||||
|
dialog.title(title)
|
||||||
|
dialog.resizable(False, False)
|
||||||
|
|
||||||
|
# Center the dialog
|
||||||
|
parent.update_idletasks()
|
||||||
|
pw, ph = parent.winfo_width(), parent.winfo_height()
|
||||||
|
px, py = parent.winfo_x(), parent.winfo_y()
|
||||||
|
dialog.geometry("360x120" + f"+{px + (pw - 360) // 2}+{py + (ph - 120) // 2}")
|
||||||
|
|
||||||
|
# Message label — directly on the dialog, no wrapper frames
|
||||||
|
msg_lbl = ctk.CTkLabel(
|
||||||
|
dialog, text=message, wraplength=320,
|
||||||
|
font=ctk.CTkFont(size=13), justify="center",
|
||||||
|
)
|
||||||
|
msg_lbl.pack(pady=(20, 8))
|
||||||
|
|
||||||
|
result = {"value": False}
|
||||||
|
|
||||||
|
btn_row = ctk.CTkFrame(dialog, fg_color="transparent")
|
||||||
|
btn_row.pack(pady=(0, 20))
|
||||||
|
|
||||||
|
def on_overwrite():
|
||||||
|
result["value"] = True
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
def on_rename():
|
||||||
|
result["value"] = False
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
btn_row, text="Overwrite", width=100,
|
||||||
|
fg_color="#059669",
|
||||||
|
hover_color="#047857",
|
||||||
|
text_color="#ffffff",
|
||||||
|
font=ctk.CTkFont(size=13, weight="bold"),
|
||||||
|
corner_radius=6,
|
||||||
|
command=on_overwrite,
|
||||||
|
).pack(side="left", padx=6)
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
btn_row, text="Rename", width=100,
|
||||||
|
fg_color="#6b7280",
|
||||||
|
hover_color="#4b5563",
|
||||||
|
text_color="#ffffff",
|
||||||
|
font=ctk.CTkFont(size=13, weight="bold"),
|
||||||
|
corner_radius=6,
|
||||||
|
command=on_rename,
|
||||||
|
).pack(side="left", padx=6)
|
||||||
|
|
||||||
|
dialog.update_idletasks()
|
||||||
|
dialog.grab_set()
|
||||||
|
return result["value"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main Application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TransmutateApp:
|
||||||
|
"""Main CustomTkinter application for Transmutate."""
|
||||||
|
|
||||||
|
def __init__(self, root: ctk.CTk, filepath: str):
|
||||||
|
self.root = root
|
||||||
|
self.config = ConversionConfig()
|
||||||
|
self.config.source_file = os.path.abspath(filepath)
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if not os.path.isfile(self.config.source_file):
|
||||||
|
_die(root, f"File does not exist: {filepath}")
|
||||||
|
|
||||||
|
self.config.mime_type = detect_mime(self.config.source_file)
|
||||||
|
self.config.media_type = detect_media_type(self.config.mime_type)
|
||||||
|
|
||||||
|
if not self.config.media_type:
|
||||||
|
_die(root, f"Unsupported file type: {self.config.mime_type}")
|
||||||
|
|
||||||
|
if not has_ffmpeg():
|
||||||
|
_die(root, "ffmpeg is not installed or not in PATH")
|
||||||
|
|
||||||
|
probe = probe_file(self.config.source_file, self.config.mime_type)
|
||||||
|
self.config.is_animated = probe.is_animated
|
||||||
|
self.streams = list(probe.streams) # copy to avoid mutation
|
||||||
|
|
||||||
|
base = os.path.basename(self.config.source_file)
|
||||||
|
self._input_name, _ = os.path.splitext(base)
|
||||||
|
self._input_dir = os.path.dirname(self.config.source_file)
|
||||||
|
|
||||||
|
# Build UI
|
||||||
|
self.root.title("Transmutate")
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# UI Construction
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
"""Build the main application window."""
|
||||||
|
self.root.geometry("720x720")
|
||||||
|
self.root.minsize(480, 400)
|
||||||
|
|
||||||
|
main = ctk.CTkFrame(self.root)
|
||||||
|
main.pack(fill="both", expand=True, padx=16, pady=16)
|
||||||
|
|
||||||
|
self._build_info_header(main)
|
||||||
|
self._build_category_buttons(main)
|
||||||
|
|
||||||
|
self.options_frame = ctk.CTkScrollableFrame(
|
||||||
|
main, fg_color="transparent",
|
||||||
|
label_text="Options", label_font=ctk.CTkFont(size=11, weight="bold"),
|
||||||
|
)
|
||||||
|
self.options_frame.pack(fill="both", expand=True, pady=(8, 4))
|
||||||
|
|
||||||
|
self._build_bottom(main)
|
||||||
|
|
||||||
|
def _build_info_header(self, parent):
|
||||||
|
"""Display file info (name, media type, extension) at the top."""
|
||||||
|
info_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
info_frame.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
media_label = self.config.media_type.upper()
|
||||||
|
ext = os.path.splitext(self.config.source_file)[1].lstrip(".").upper()
|
||||||
|
self.info_label = ctk.CTkLabel(
|
||||||
|
info_frame,
|
||||||
|
text=f"📁 {os.path.basename(self.config.source_file)} "
|
||||||
|
f"[{media_label} · {ext}]",
|
||||||
|
font=ctk.CTkFont(family="", size=10),
|
||||||
|
)
|
||||||
|
self.info_label.pack(anchor="center")
|
||||||
|
|
||||||
|
if self.config.is_animated:
|
||||||
|
ctk.CTkLabel(info_frame, text=" ⚡ Animated", text_color="#b87333").pack(anchor="center")
|
||||||
|
|
||||||
|
def _build_category_buttons(self, parent):
|
||||||
|
"""Source-to-target type selector buttons (Image / Video / Audio)."""
|
||||||
|
btn_frame = ctk.CTkFrame(parent)
|
||||||
|
btn_frame.pack(fill="x", pady=(0, 4))
|
||||||
|
|
||||||
|
ctk.CTkLabel(btn_frame, text="Convert to:", font=ctk.CTkFont(family="", size=11)).pack()
|
||||||
|
|
||||||
|
target_types = {"image": "Image", "video": "Video", "audio": "Audio"}
|
||||||
|
current = self.config.media_type
|
||||||
|
|
||||||
|
row_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
|
||||||
|
row_frame.pack()
|
||||||
|
|
||||||
|
for key, label in target_types.items():
|
||||||
|
if key == current:
|
||||||
|
state = "normal"
|
||||||
|
elif self.config.media_type == "video" and key == "image":
|
||||||
|
state = "disabled"
|
||||||
|
elif self.config.media_type == "image" and key in ("video", "audio"):
|
||||||
|
state = "disabled"
|
||||||
|
elif self.config.media_type == "audio" and key in ("video", "image"):
|
||||||
|
state = "disabled"
|
||||||
|
else:
|
||||||
|
state = "normal"
|
||||||
|
btn = ctk.CTkButton(
|
||||||
|
row_frame, text=label,
|
||||||
|
command=lambda k=key: self._on_target_type(k),
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
btn.pack(side="left", padx=(4, 4))
|
||||||
|
|
||||||
|
def _build_bottom(self, parent):
|
||||||
|
"""Conversion button anchored at the bottom."""
|
||||||
|
bottom_container = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
bottom_container.pack(fill="x", side="bottom", pady=(4, 12))
|
||||||
|
bottom_container.pack_propagate(False)
|
||||||
|
|
||||||
|
self.transform_btn = ctk.CTkButton(
|
||||||
|
bottom_container, text="Transmutate", command=self._on_transform,
|
||||||
|
font=ctk.CTkFont(family="", size=12, weight="bold"),
|
||||||
|
height=36,
|
||||||
|
width=160,
|
||||||
|
)
|
||||||
|
self.transform_btn.pack()
|
||||||
|
|
||||||
|
# Disable until user sets options
|
||||||
|
self.transform_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
# Dynamic option panels
|
||||||
|
|
||||||
|
def _on_target_type(self, target_type: str):
|
||||||
|
"""Switch to a new conversion target panel."""
|
||||||
|
self.config.quality = 85
|
||||||
|
self.config.audio_quality = 85
|
||||||
|
self.config.loop = False
|
||||||
|
self.config.audio_streams = []
|
||||||
|
self.config.sub_streams = []
|
||||||
|
self.transform_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
for w in self.options_frame.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
|
||||||
|
# Clear stale audio-quality widget references
|
||||||
|
for attr in ("_audio_quality_frame", "_audio_quality_slider",
|
||||||
|
"_audio_quality_var", "_audio_quality_label",
|
||||||
|
"_audio_quality_title_label", "_audio_quality_widgets"):
|
||||||
|
if hasattr(self, attr):
|
||||||
|
delattr(self, attr)
|
||||||
|
|
||||||
|
dispatch = {
|
||||||
|
("image", "image"): self._build_image_image_panel,
|
||||||
|
("image", "video"): self._build_image_video_panel,
|
||||||
|
("image", "audio"): self._build_image_audio_panel,
|
||||||
|
("video", "image"): self._build_video_image_panel,
|
||||||
|
("video", "video"): self._build_video_video_panel,
|
||||||
|
("video", "audio"): self._build_video_audio_panel,
|
||||||
|
("audio", "image"): self._build_audio_image_panel,
|
||||||
|
("audio", "video"): self._build_audio_video_panel,
|
||||||
|
("audio", "audio"): self._build_audio_audio_panel,
|
||||||
|
}
|
||||||
|
fn = dispatch.get((self.config.media_type, target_type))
|
||||||
|
if fn:
|
||||||
|
fn()
|
||||||
|
if hasattr(self, "_format_var"):
|
||||||
|
self.config.target_format = self._format_var.get()
|
||||||
|
self.transform_btn.configure(state="normal")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helper: create options panel widgets
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add_radio_group(self, parent, formats: dict[str, str], default_key: str):
|
||||||
|
"""Add format radio buttons inside *parent*. Returns the ``StringVar``."""
|
||||||
|
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
frame.pack(fill="x", pady=(4, 0))
|
||||||
|
|
||||||
|
ctk.CTkLabel(frame, text="Format:", font=ctk.CTkFont(family="", size=11, weight="bold")).pack(anchor="center")
|
||||||
|
|
||||||
|
radios_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
|
radios_frame.pack(fill="x", pady=(2, 0))
|
||||||
|
|
||||||
|
selected = ctk.StringVar(value=default_key)
|
||||||
|
|
||||||
|
for key, label in formats.items():
|
||||||
|
rb = ctk.CTkRadioButton(
|
||||||
|
radios_frame, text=label, value=key,
|
||||||
|
variable=selected, command=self._on_format_change,
|
||||||
|
)
|
||||||
|
rb.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
|
# Track it on self for later reading
|
||||||
|
self._format_var = selected
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def _add_quality_slider(self, parent, default: int = 85,
|
||||||
|
label_text: str = "Quality: ",
|
||||||
|
crf_max: int = 0):
|
||||||
|
"""Add a quality slider and label, returning the parent ``Frame``.
|
||||||
|
|
||||||
|
When *crf_max* > 0 (CRF mode) the slider runs *crf_max .. 0* so
|
||||||
|
the right side is best quality (lowest CRF). *default* is on the
|
||||||
|
user-facing scale (0 – crf_max).
|
||||||
|
|
||||||
|
When *crf_max* == 0 the slider runs 0 – 100 (higher = better).
|
||||||
|
"""
|
||||||
|
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
frame.pack(fill="x", pady=(4, 0))
|
||||||
|
|
||||||
|
# Use grid for everything in this frame
|
||||||
|
frame.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
qty_label_text = ctk.CTkLabel(frame, text=label_text, font=ctk.CTkFont(family="", size=11, weight="bold"))
|
||||||
|
qty_label_text.grid(row=0, column=0, columnspan=2, pady=(0, 2))
|
||||||
|
|
||||||
|
qty = ctk.IntVar(value=default)
|
||||||
|
|
||||||
|
# CRF: slider right → best quality (lower CRF), i.e. crf_max down to 0
|
||||||
|
# Standard: slider right → better quality, i.e. 0 up to 100
|
||||||
|
if crf_max > 0:
|
||||||
|
slider = ctk.CTkSlider(
|
||||||
|
frame, from_=crf_max, to=0,
|
||||||
|
variable=qty,
|
||||||
|
command=self._on_quality_scale_clamped,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
slider = ctk.CTkSlider(
|
||||||
|
frame, from_=0, to=100,
|
||||||
|
variable=qty,
|
||||||
|
command=self._on_quality_scale_clamped,
|
||||||
|
)
|
||||||
|
slider.grid(row=1, column=0, sticky="ew", columnspan=2, padx=(0, 8))
|
||||||
|
|
||||||
|
self._quality_display_label = ctk.CTkLabel(frame, text=str(default), width=40, anchor="e")
|
||||||
|
self._quality_display_label.grid(row=1, column=1)
|
||||||
|
|
||||||
|
self._quality_var = qty
|
||||||
|
self._quality_frame = frame
|
||||||
|
self._quality_slider = slider
|
||||||
|
self._quality_mode = "standard"
|
||||||
|
self._crf_max = crf_max
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _on_quality_scale_clamped(self, val):
|
||||||
|
"""Clamp slider value to bounds, then update display."""
|
||||||
|
v = int(float(val))
|
||||||
|
qty_var = self._quality_var
|
||||||
|
crf_max = getattr(self, "_crf_max", 0)
|
||||||
|
|
||||||
|
if crf_max > 0:
|
||||||
|
# CRF mode: val is in [crf_max, 0], clamp to that range
|
||||||
|
if v < 0:
|
||||||
|
v = 0
|
||||||
|
elif v > crf_max:
|
||||||
|
v = crf_max
|
||||||
|
qty_var.set(v)
|
||||||
|
self._quality_display_label.configure(text=str(v))
|
||||||
|
else:
|
||||||
|
# Standard mode: val is in [0, 100], clamp to that range
|
||||||
|
if v < 0:
|
||||||
|
v = 0
|
||||||
|
elif v > 100:
|
||||||
|
v = 100
|
||||||
|
qty_var.set(v)
|
||||||
|
self._quality_display_label.configure(text=str(v))
|
||||||
|
|
||||||
|
def _add_audio_quality_slider(self, parent, default: int = 85):
|
||||||
|
"""Add an audio-quality slider for video output, returning the parent ``Frame``."""
|
||||||
|
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
frame.pack(fill="x", pady=(4, 0))
|
||||||
|
|
||||||
|
# Use grid for everything in this frame — no pack/grid mixing.
|
||||||
|
frame.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
title_label = ctk.CTkLabel(frame, text="Audio Quality:", font=ctk.CTkFont(family="", size=11, weight="bold"))
|
||||||
|
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 2))
|
||||||
|
|
||||||
|
qty = ctk.IntVar(value=default)
|
||||||
|
|
||||||
|
slider = ctk.CTkSlider(
|
||||||
|
frame, from_=0, to=100,
|
||||||
|
variable=qty,
|
||||||
|
command=lambda val: self._on_audio_quality_scale_clamped(val, qty),
|
||||||
|
)
|
||||||
|
slider.grid(row=1, column=0, sticky="ew", columnspan=2, padx=(0, 8))
|
||||||
|
|
||||||
|
self._audio_quality_label = ctk.CTkLabel(frame, text=str(default), width=40, anchor="e")
|
||||||
|
self._audio_quality_label.grid(row=1, column=1)
|
||||||
|
|
||||||
|
self._audio_quality_var = qty
|
||||||
|
self._audio_quality_frame = frame
|
||||||
|
self._audio_quality_slider = slider
|
||||||
|
self._audio_quality_title_label = title_label
|
||||||
|
self._audio_quality_widgets = (title_label, slider, self._audio_quality_label)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _on_audio_quality_scale_clamped(self, val, qty_var):
|
||||||
|
"""Clamp audio-quality slider value to 0-100 bounds."""
|
||||||
|
v = int(float(val))
|
||||||
|
if v < 0:
|
||||||
|
v = 0
|
||||||
|
elif v > 100:
|
||||||
|
v = 100
|
||||||
|
qty_var.set(v)
|
||||||
|
self._audio_quality_label.configure(text=str(v))
|
||||||
|
|
||||||
|
def _add_loop_checkbox(self, parent):
|
||||||
|
"""Add a loop checkbox, returning the ``BooleanVar``."""
|
||||||
|
cb_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
cb_frame.pack(fill="x", pady=(2, 0))
|
||||||
|
|
||||||
|
var = ctk.BooleanVar(value=False)
|
||||||
|
cb = ctk.CTkCheckBox(cb_frame, text="Loop output", variable=var)
|
||||||
|
cb.pack(anchor="center")
|
||||||
|
self._loop_var = var
|
||||||
|
self._loop_frame = cb_frame
|
||||||
|
return var
|
||||||
|
|
||||||
|
def _add_stream_selection(self, parent, streams: list[StreamInfo], stream_type: str = ""):
|
||||||
|
"""Add checkboxes for selecting audio/subtitle streams."""
|
||||||
|
if not streams:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
frame.pack(fill="x", pady=(4, 0))
|
||||||
|
|
||||||
|
header = f"Select {stream_type}" if stream_type else "Streams"
|
||||||
|
ctk.CTkLabel(frame, text=f"{header}:", font=ctk.CTkFont(family="", size=11, weight="bold")).pack(anchor="center")
|
||||||
|
|
||||||
|
checkboxes_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
|
checkboxes_frame.pack(fill="x", pady=(2, 0))
|
||||||
|
|
||||||
|
vars_dict = {}
|
||||||
|
for stream in streams:
|
||||||
|
var = ctk.BooleanVar(value=True) # default: all selected
|
||||||
|
cb = ctk.CTkCheckBox(
|
||||||
|
checkboxes_frame, text=stream.label(), variable=var,
|
||||||
|
)
|
||||||
|
cb.pack(anchor="center")
|
||||||
|
vars_dict[stream.index] = var
|
||||||
|
|
||||||
|
self._stream_vars = vars_dict
|
||||||
|
return vars_dict
|
||||||
|
|
||||||
|
def _maybe_update_transform(self):
|
||||||
|
"""Enable/disable the conversion button based on current selections."""
|
||||||
|
has_format = hasattr(self, "_format_var") and self.config.target_format
|
||||||
|
self.transform_btn.configure(state="normal" if has_format else "disabled")
|
||||||
|
|
||||||
|
def _on_format_change(self):
|
||||||
|
"""Handle format selection change."""
|
||||||
|
fmt = self._format_var.get()
|
||||||
|
self.config.target_format = fmt
|
||||||
|
self._maybe_update_transform()
|
||||||
|
self._maybe_update_quality_controls()
|
||||||
|
|
||||||
|
def _maybe_update_quality_controls(self):
|
||||||
|
"""Show/hide quality controls based on the current format."""
|
||||||
|
fmt = getattr(self, "_format_var", None)
|
||||||
|
if fmt is None:
|
||||||
|
return
|
||||||
|
fmt = fmt.get()
|
||||||
|
|
||||||
|
# Determine which quality mode to show
|
||||||
|
show_crf = fmt in VIDEO_FORMATS and fmt not in ("gif", "webp")
|
||||||
|
show_loop = fmt in ("gif", "webp")
|
||||||
|
|
||||||
|
# Show/hide quality slider label dynamically
|
||||||
|
if hasattr(self, "_quality_var"):
|
||||||
|
# Find the quality slider's label and update text
|
||||||
|
if hasattr(self, "_quality_frame"):
|
||||||
|
for child in self._quality_frame.winfo_children():
|
||||||
|
if isinstance(child, ctk.CTkLabel):
|
||||||
|
if show_crf:
|
||||||
|
child.configure(text="Video CRF (0-51): ")
|
||||||
|
elif show_loop:
|
||||||
|
child.configure(text="Quality (0-100): ")
|
||||||
|
else:
|
||||||
|
child.configure(text="Quality (0-100): ")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Toggle quality slider range and label text
|
||||||
|
if hasattr(self, "_quality_var") and hasattr(self, "_quality_slider") and hasattr(self, "_quality_display_label"):
|
||||||
|
# Determine the target quality mode
|
||||||
|
if show_crf:
|
||||||
|
target_mode = "crf"
|
||||||
|
elif show_loop:
|
||||||
|
target_mode = "loop"
|
||||||
|
else:
|
||||||
|
target_mode = "standard"
|
||||||
|
|
||||||
|
# Only reset slider value when actually changing modes
|
||||||
|
mode_changed = target_mode != getattr(self, "_quality_mode", None)
|
||||||
|
|
||||||
|
if mode_changed:
|
||||||
|
if show_crf:
|
||||||
|
# Slider already reports 0..crf_max directly in CRF mode
|
||||||
|
self._quality_var.set(23)
|
||||||
|
self._quality_display_label.configure(text="23")
|
||||||
|
elif show_loop:
|
||||||
|
self._quality_var.set(50)
|
||||||
|
self._quality_display_label.configure(text="50")
|
||||||
|
else:
|
||||||
|
self._quality_var.set(85)
|
||||||
|
self._quality_display_label.configure(text="85")
|
||||||
|
|
||||||
|
self._quality_mode = target_mode
|
||||||
|
|
||||||
|
# Show/hide audio quality slider
|
||||||
|
if hasattr(self, "_audio_quality_frame"):
|
||||||
|
if show_crf:
|
||||||
|
self._audio_quality_frame.pack(fill="x", pady=(4, 0))
|
||||||
|
else:
|
||||||
|
self._audio_quality_frame.pack_forget()
|
||||||
|
|
||||||
|
# Show/hide loop checkbox
|
||||||
|
if hasattr(self, "_loop_var") and hasattr(self, "_loop_frame"):
|
||||||
|
if show_loop:
|
||||||
|
self._loop_frame.pack()
|
||||||
|
else:
|
||||||
|
self._loop_frame.pack_forget()
|
||||||
|
|
||||||
|
# Unified panel builders
|
||||||
|
|
||||||
|
def _build_image_image_panel(self):
|
||||||
|
formats = ANIMATED_IMAGE_FORMATS if self.config.is_animated else IMAGE_FORMATS
|
||||||
|
self._add_radio_group(self.options_frame, formats, "png")
|
||||||
|
self._add_quality_slider(self.options_frame, default=85,
|
||||||
|
label_text="Quality (0-100): ")
|
||||||
|
self._add_loop_checkbox(self.options_frame)
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_image_video_panel(self):
|
||||||
|
self._add_radio_group(self.options_frame, {
|
||||||
|
"mp4": "MP4", "mkv": "MKV", "webm": "WebM",
|
||||||
|
"avi": "AVI", "mov": "MOV",
|
||||||
|
}, "mp4")
|
||||||
|
self._add_quality_slider(self.options_frame, default=23,
|
||||||
|
label_text="Video CRF (0-51): ",
|
||||||
|
crf_max=51)
|
||||||
|
self._add_audio_quality_slider(self.options_frame, default=85)
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_image_audio_panel(self):
|
||||||
|
self._add_radio_group(self.options_frame, AUDIO_FORMATS, "mp3")
|
||||||
|
self._add_quality_slider(self.options_frame, default=85,
|
||||||
|
label_text="Quality (0-100): ")
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_video_image_panel(self):
|
||||||
|
self._add_radio_group(self.options_frame, {
|
||||||
|
"png": "PNG", "jpg": "JPG", "webp": "WebP", "avif": "AVIF",
|
||||||
|
}, "png")
|
||||||
|
self._add_quality_slider(self.options_frame, default=85,
|
||||||
|
label_text="Quality (0-100): ")
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_video_video_panel(self):
|
||||||
|
# Audio streams
|
||||||
|
audio_streams = [s for s in self.streams if s.codec_type == "audio"]
|
||||||
|
if len(audio_streams) == 0:
|
||||||
|
pass
|
||||||
|
elif len(audio_streams) == 1:
|
||||||
|
self.config.audio_streams = [audio_streams[0].index]
|
||||||
|
else:
|
||||||
|
self._add_stream_selection(
|
||||||
|
self.options_frame, audio_streams, "audio",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subtitle streams
|
||||||
|
sub_streams = [s for s in self.streams if s.codec_type == "subtitle"]
|
||||||
|
if len(sub_streams) == 1:
|
||||||
|
self.config.sub_streams = [sub_streams[0].index]
|
||||||
|
elif len(sub_streams) > 1:
|
||||||
|
self._add_stream_selection(
|
||||||
|
self.options_frame, sub_streams, "subtitle",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular video formats
|
||||||
|
self._add_radio_group(self.options_frame, {
|
||||||
|
"mp4": "MP4", "mkv": "MKV", "webm": "WebM",
|
||||||
|
"avi": "AVI", "mov": "MOV",
|
||||||
|
}, "mp4")
|
||||||
|
|
||||||
|
# GIF / WebP on a separate line below
|
||||||
|
gif_webp_frame = ctk.CTkFrame(self.options_frame, fg_color="transparent")
|
||||||
|
gif_webp_frame.pack(fill="x", pady=(4, 0))
|
||||||
|
ctk.CTkLabel(gif_webp_frame, text="Animated:", font=ctk.CTkFont(family="", size=11, weight="bold")).pack(anchor="center")
|
||||||
|
for fmt_key, fmt_label in [("gif", "GIF"), ("webp", "WebP (animated)")]:
|
||||||
|
rb = ctk.CTkRadioButton(
|
||||||
|
gif_webp_frame, text=fmt_label, value=fmt_key,
|
||||||
|
variable=self._format_var,
|
||||||
|
command=self._on_format_change,
|
||||||
|
)
|
||||||
|
rb.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
|
# Quality controls
|
||||||
|
self._add_quality_slider(self.options_frame, default=23,
|
||||||
|
label_text="Video CRF (0-51): ",
|
||||||
|
crf_max=51)
|
||||||
|
self._add_audio_quality_slider(self.options_frame, default=85)
|
||||||
|
self._add_loop_checkbox(self.options_frame)
|
||||||
|
self._maybe_update_transform()
|
||||||
|
self._maybe_update_quality_controls()
|
||||||
|
|
||||||
|
def _build_video_audio_panel(self):
|
||||||
|
audio_streams = [s for s in self.streams if s.codec_type == "audio"]
|
||||||
|
if len(audio_streams) == 0:
|
||||||
|
ctk.CTkLabel(self.options_frame, text="No audio tracks found in this video.",
|
||||||
|
text_color="#cc6666").pack(anchor="center", pady=(4, 0))
|
||||||
|
ctk.CTkLabel(self.options_frame, text="Cannot convert video without audio.",
|
||||||
|
text_color="gray").pack(anchor="center")
|
||||||
|
self.transform_btn.configure(state="disabled")
|
||||||
|
return
|
||||||
|
if len(audio_streams) == 1:
|
||||||
|
self.config.audio_streams = [audio_streams[0].index]
|
||||||
|
else:
|
||||||
|
self._add_stream_selection(
|
||||||
|
self.options_frame, audio_streams, "audio",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_radio_group(self.options_frame, AUDIO_FORMATS, "mp3")
|
||||||
|
self._add_quality_slider(self.options_frame, default=85,
|
||||||
|
label_text="Quality (0-100): ")
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_audio_image_panel(self):
|
||||||
|
self._add_radio_group(self.options_frame, {
|
||||||
|
"png": "PNG", "jpg": "JPG", "webp": "WebP", "avif": "AVIF",
|
||||||
|
}, "png")
|
||||||
|
self._add_quality_slider(self.options_frame, default=85,
|
||||||
|
label_text="Quality (0-100): ")
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_audio_video_panel(self):
|
||||||
|
self._add_radio_group(self.options_frame, {
|
||||||
|
"mp4": "MP4", "mkv": "MKV", "webm": "WebM",
|
||||||
|
"avi": "AVI", "mov": "MOV",
|
||||||
|
}, "mp4")
|
||||||
|
self._add_quality_slider(self.options_frame, default=23,
|
||||||
|
label_text="Video CRF (0-51): ",
|
||||||
|
crf_max=51)
|
||||||
|
self._add_audio_quality_slider(self.options_frame, default=85)
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
def _build_audio_audio_panel(self):
|
||||||
|
audio_streams = [s for s in self.streams if s.codec_type == "audio"]
|
||||||
|
if len(audio_streams) == 0:
|
||||||
|
ctk.CTkLabel(self.options_frame, text="No audio streams found in file.",
|
||||||
|
text_color="#cc6666").pack(anchor="center", pady=(4, 0))
|
||||||
|
ctk.CTkLabel(self.options_frame, text="Cannot convert without audio.",
|
||||||
|
text_color="gray").pack(anchor="center")
|
||||||
|
self.transform_btn.configure(state="disabled")
|
||||||
|
return
|
||||||
|
if len(audio_streams) == 1:
|
||||||
|
self.config.audio_streams = [audio_streams[0].index]
|
||||||
|
else:
|
||||||
|
self._add_stream_selection(
|
||||||
|
self.options_frame, audio_streams, "audio",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_radio_group(self.options_frame, AUDIO_FORMATS, "mp3")
|
||||||
|
self._add_quality_slider(self.options_frame, default=85,
|
||||||
|
label_text="Quality (0-100): ")
|
||||||
|
self._maybe_update_transform()
|
||||||
|
|
||||||
|
# Action handlers
|
||||||
|
|
||||||
|
def _on_transform(self):
|
||||||
|
"""Execute the conversion in a background thread."""
|
||||||
|
# Gather values
|
||||||
|
self.config.target_format = self._format_var.get()
|
||||||
|
quality_val = self._quality_var.get()
|
||||||
|
# Slider already reports the actual quality value directly
|
||||||
|
# (CRF mode: 0..crf_max; Standard mode: 0..100)
|
||||||
|
self.config.quality = quality_val
|
||||||
|
|
||||||
|
self.config.audio_quality = getattr(self, "_audio_quality_var", None) and self._audio_quality_var.get() or 85
|
||||||
|
self.config.loop = getattr(self, "_loop_var", None) and self._loop_var.get() or False
|
||||||
|
|
||||||
|
# Collect selected streams
|
||||||
|
if hasattr(self, "_stream_vars"): # stream selection only used for audio video conversions
|
||||||
|
self.config.audio_streams = [
|
||||||
|
idx for idx, var in self._stream_vars.items()
|
||||||
|
if var.get()
|
||||||
|
]
|
||||||
|
if not self.config.audio_streams:
|
||||||
|
msg = "No audio streams selected. Conversion may fail."
|
||||||
|
print(f"Transmutate — Warning: {msg}")
|
||||||
|
_show_message(self.root, "Transmutate — Warning", msg, kind="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
out_ext = self._ext_for_format(self.config.target_format)
|
||||||
|
self.config.output_file = os.path.join(
|
||||||
|
self._input_dir, f"{self._input_name}.{out_ext}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check overwrite
|
||||||
|
if os.path.exists(self.config.output_file):
|
||||||
|
if not _ask_question(
|
||||||
|
self.root,
|
||||||
|
"File Exists",
|
||||||
|
f"{os.path.basename(self.config.output_file)} already exists.",
|
||||||
|
):
|
||||||
|
# Auto-rename
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
renamed = os.path.join(
|
||||||
|
self._input_dir,
|
||||||
|
f"{self._input_name}[{counter}].{out_ext}",
|
||||||
|
)
|
||||||
|
if not os.path.exists(renamed):
|
||||||
|
self.config.output_file = renamed
|
||||||
|
break
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Preflight check
|
||||||
|
ok, err_msg = preflight_check(self.config.source_file)
|
||||||
|
if not ok:
|
||||||
|
print(f"Transmutate — Error: {err_msg}")
|
||||||
|
_show_message(self.root, "Transmutate — Error", err_msg, kind="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
try:
|
||||||
|
cmd = self._build_command()
|
||||||
|
except ValueError as e:
|
||||||
|
msg = str(e)
|
||||||
|
print(f"Transmutate — Error: {msg}")
|
||||||
|
_show_message(self.root, "Transmutate — Error", msg, kind="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Disable UI during conversion
|
||||||
|
self._set_ui_enabled(False)
|
||||||
|
self.transform_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
def run_conversion():
|
||||||
|
try:
|
||||||
|
self.transform_btn.configure(text="Converting…", state="disabled")
|
||||||
|
|
||||||
|
success, msg = run_command(cmd)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.root.after(0, lambda: _show_message(
|
||||||
|
self.root, "Transmutation Complete", "Conversion finished.", kind="info"))
|
||||||
|
else:
|
||||||
|
error_msg = f"Conversion failed.\n\n{msg}" if msg else "Unknown error."
|
||||||
|
print(f"Transmutate — Error: {error_msg}")
|
||||||
|
self.root.after(0, lambda m=error_msg: _show_message(
|
||||||
|
self.root, "Transmutate — Error", m, kind="error"))
|
||||||
|
except Exception as exc:
|
||||||
|
exc_val = str(exc)
|
||||||
|
print(f"Transmutate — Error: {exc_val}")
|
||||||
|
self.root.after(0, lambda e=exc_val: _show_message(
|
||||||
|
self.root, "Transmutate — Error", e, kind="error"))
|
||||||
|
finally:
|
||||||
|
self.root.after(0, lambda: self.transform_btn.configure(text="Transmutate", state="normal"))
|
||||||
|
|
||||||
|
threading.Thread(target=run_conversion, daemon=True).start()
|
||||||
|
|
||||||
|
def _set_ui_enabled(self, enabled: bool):
|
||||||
|
"""Enable/disable all interactive widgets."""
|
||||||
|
state = "normal" if enabled else "disabled"
|
||||||
|
for widget in self.options_frame.winfo_children():
|
||||||
|
self._set_child_states(widget, state)
|
||||||
|
self.transform_btn.configure(state=state)
|
||||||
|
if not enabled:
|
||||||
|
self.info_label.configure(text_color="#888")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_child_states(parent, state):
|
||||||
|
"""Recursively set state on all child widgets."""
|
||||||
|
try:
|
||||||
|
children = parent.winfo_children()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for child in children:
|
||||||
|
if isinstance(child, (ctk.CTkCheckBox, ctk.CTkRadioButton, ctk.CTkSlider)):
|
||||||
|
child.configure(state=state)
|
||||||
|
elif isinstance(child, ctk.CTkButton) and child is not None:
|
||||||
|
child.configure(state=state)
|
||||||
|
else:
|
||||||
|
TransmutateApp._set_child_states(child, state)
|
||||||
|
|
||||||
|
# Command builder
|
||||||
|
|
||||||
|
def _build_command(self):
|
||||||
|
"""Build an ffmpeg/magick command list for the current conversion."""
|
||||||
|
src = self.config.source_file
|
||||||
|
dst = self.config.output_file
|
||||||
|
fmt = self.config.target_format
|
||||||
|
quality = self.config.quality
|
||||||
|
aq = self.config.audio_quality
|
||||||
|
audio_streams = self.config.audio_streams
|
||||||
|
sub_streams = self.config.sub_streams
|
||||||
|
is_animated = self.config.is_animated
|
||||||
|
loop = self.config.loop
|
||||||
|
mime = self.config.mime_type
|
||||||
|
|
||||||
|
media = self.config.media_type
|
||||||
|
|
||||||
|
if media == "image":
|
||||||
|
return _build_image_command(
|
||||||
|
src, dst, fmt, quality,
|
||||||
|
is_animated, loop, mime,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif media == "video":
|
||||||
|
if fmt == "gif":
|
||||||
|
# Video → GIF: use palettegen-based command
|
||||||
|
return _build_video_gif(src, dst, quality)
|
||||||
|
elif fmt == "webp":
|
||||||
|
# Video → animated WebP: use libwebp_anim
|
||||||
|
return _build_video_webp(src, dst, quality, loop)
|
||||||
|
elif fmt in AUDIO_FORMATS:
|
||||||
|
# Video → audio (e.g. MP4 → MP3)
|
||||||
|
return _build_audio_command(
|
||||||
|
src, dst, fmt, quality, audio_streams,
|
||||||
|
)
|
||||||
|
return _build_video_command(
|
||||||
|
src, dst, fmt, quality, aq,
|
||||||
|
audio_streams, sub_streams, mime,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif media == "audio":
|
||||||
|
return _build_audio_command(
|
||||||
|
src, dst, fmt, quality, audio_streams,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown media type: {media}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ext_for_format(fmt: str) -> str:
|
||||||
|
"""Map a format key to its file extension."""
|
||||||
|
return {
|
||||||
|
"mp3": "mp3", "flac": "flac", "wav": "wav",
|
||||||
|
"ogg": "ogg", "m4a": "m4a", "aac": "aac",
|
||||||
|
"png": "png", "jpg": "jpg", "webp": "webp",
|
||||||
|
"avif": "avif", "gif": "gif",
|
||||||
|
"mp4": "mp4", "mkv": "mkv", "webm": "webm",
|
||||||
|
"avi": "avi", "mov": "mov",
|
||||||
|
}.get(fmt, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def open_file(filepath: str):
|
||||||
|
"""Entry point: launch the Transmutate GUI for *filepath*."""
|
||||||
|
ctk.set_appearance_mode("System")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
root = ctk.CTk()
|
||||||
|
TransmutateApp(root, filepath)
|
||||||
|
root.mainloop()
|
||||||
Reference in New Issue
Block a user