diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..316a072 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 2bec551..2ef65a1 100644 --- a/README.md +++ b/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 ```bash -./transmutate.sh +cd rewrite +python transmutate.py ``` +The GUI opens automatically and detects the media type of the file. + ## 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 +### Supported Conversions -## 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** -- **FFmpeg** -- **ImageMagick** (`magick` CLI) — optional, used for better GIF and WebP conversion +### Per-Conversion Options + +- **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 | diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..2bec551 --- /dev/null +++ b/legacy/README.md @@ -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 +``` + +## Features + +- Select output format, quality, and audio/subtitle tracks via KDE dialogs +- Convert **images** to PNG, JPG, WebP, AVIF, GIF, or video formats (MP4, MKV, WebM, AVI, MOV) +- Convert **videos** to MP4, MKV, WebM, AVI, MOV, GIF, or animated WebP +- Convert **audio** to MP3, FLAC, WAV, OGG, M4A, or AAC +- Animated GIFs and WebPs can be converted to full video formats +- GIF/WebP animation options (looping) +- If multiple audio or subtitle tracks exist, choose which to include +- Handles filename conflicts by prompting to overwrite or auto-renaming + +## Requirements + +- **KDE / KDialog** +- **FFmpeg** +- **ImageMagick** (`magick` CLI) — optional, used for better GIF and WebP conversion diff --git a/transmutate.sh b/legacy/transmutate.sh similarity index 81% rename from transmutate.sh rename to legacy/transmutate.sh index 09e099b..90a6ce4 100755 --- a/transmutate.sh +++ b/legacy/transmutate.sh @@ -20,10 +20,6 @@ die() { exit 1 } -quit() { - exit 1 -} - has_magick() { command -v magick >/dev/null 2>&1 } @@ -36,13 +32,14 @@ ask_quality() { local media="$1" local default_val="${2:-85}" local prompt min_val max_val + local input case "$media" in image) prompt="Quality (0-100):" min_val=0; max_val=100 ;; video) - # CRF scale: 0=best/lossless, 51=worst. Lower = better quality. + # CRF scale flipped: 51=best/lossless, 0=worst. Higher = better quality. prompt="Quality (51-0) CRF:" min_val=0; max_val=51 ;; audio) @@ -55,13 +52,16 @@ ask_quality() { esac while true; do - local input 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 - echo "$default_val" + if [[ "$media" == "video" ]]; then + echo $(( 51 - default_val )) + else + echo "$default_val" + fi return 0 fi @@ -73,7 +73,13 @@ ask_quality() { continue fi - echo "$input" + # ── Invert CRF for video: user 51 (best) → ffmpeg 0, user 0 (worst) → ffmpeg 51 ── + if [[ "$media" == "video" ]]; then + local crf_inverted=$(( 51 - input )) + echo "$crf_inverted" + else + echo "$input" + fi return 0 done } @@ -131,12 +137,12 @@ case "$MEDIA_TYPE" in MENU_ARGS+=("gif" "GIF" "mp4" "MP4" "mkv" "MKV" "webm" "WebM" "avi" "AVI" "mov" "MOV") fi - SELECTED=$(kdialog --title "Transmutate — Image Conversion" --menu \ + TARGET=$(kdialog --title "Transmutate — Image Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ - "${MENU_ARGS[@]}") || quit + "${MENU_ARGS[@]}") || exit ;; video) - SELECTED=$(kdialog --title "Transmutate — Video Conversion" --menu \ + TARGET=$(kdialog --title "Transmutate — Video Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "mp4" "MP4" \ "mkv" "MKV" \ @@ -144,55 +150,46 @@ case "$MEDIA_TYPE" in "avi" "AVI" \ "mov" "MOV" \ "gif" "GIF" \ - "webp" "WebP (animated)") || quit + "webp" "WebP (animated)") || exit ;; audio) - SELECTED=$(kdialog --title "Transmutate — Audio Conversion" --menu \ + TARGET=$(kdialog --title "Transmutate — Audio Conversion" --menu \ "Choose output format for: $INPUT_BASENAME" \ "mp3" "MP3" \ "flac" "FLAC" \ "wav" "WAV" \ "ogg" "OGG" \ "m4a" "M4A" \ - "aac" "AAC") || quit - ;; + "aac" "AAC") || exit + ;; esac -TARGET="$SELECTED" -echo "DEBUG: SELECTED='$SELECTED' TARGET='$TARGET'" - # ─── 4.5. Stream selection (video/audio only) ────────────────────────────────── -# Probes the file for audio and subtitle tracks. -# Single track → auto-carried. Multiple tracks → checklist to pick. -# No selection for audio means "take first audio". No selection for subs means "none". - -# Arrays to hold selected stream indices -map_audio=() # ffmpeg indices of audio streams to carry over -map_sub=() # ffmpeg indices of subtitle streams to carry over +map_audio=() +map_sub=() if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then - # Probe all streams: index, codec_type, codec_name, language - # Use key=value output — ffprobe csv doesn't guarantee field order + # Probe all streams STREAMS_RAW=$(ffprobe -v error \ -show_entries stream=index,codec_type,codec_name,language \ -of default=noprint_wrappers=1 "$INPUT_FILE" 2>/dev/null) || STREAMS_RAW="" - # Parse key=value pairs per stream block - # Each stream has: index=X, codec_type=..., codec_name=..., language=... - idx=0 - audio_entries=() - audio_real_idx=() - sub_entries=() - sub_real_idx=() + # We will store the ABSOLUTE ffmpeg indices here + audio_absolute_indices=() + audio_labels=() + sub_absolute_indices=() + sub_labels=() + + # Temporary variables for parsing current_idx="" current_type="" current_name="" current_lang="" while IFS='=' read -r key value; do - # Strip whitespace from key key=$(echo "$key" | tr -d ' ') value=$(echo "$value" | tr -d ' ') + case "$key" in index) current_idx="$value" ;; codec_type) current_type="$value" ;; @@ -200,28 +197,18 @@ if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then language) current_lang="$value" ;; 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 [[ "$current_type" == "audio" ]]; then - if [[ -n "$current_lang" && "$current_lang" != "und" ]]; then - label="Audio #$current_idx — $current_name (${current_lang})" - else - label="Audio #$current_idx — $current_name" - fi - audio_entries+=("$((idx + 1))" "$label" "on") - audio_real_idx+=("$idx") - idx=$(( idx + 1 )) + label="Audio #$current_idx — $current_name${current_lang:+ (${current_lang})}" + audio_absolute_indices+=("$current_idx") + audio_labels+=("$label") elif [[ "$current_type" == "subtitle" ]]; then - if [[ -n "$current_lang" && "$current_lang" != "und" ]]; then - label="Subtitle #$current_idx — $current_name (${current_lang})" - else - label="Subtitle #$current_idx — $current_name" - fi - sub_entries+=("$((idx + 1))" "$label" "on") - sub_real_idx+=("$idx") - idx=$(( idx + 1 )) + label="Subtitle #$current_idx — $current_name${current_lang:+ (${current_lang})}" + sub_absolute_indices+=("$current_idx") + sub_labels+=("$label") fi - # Reset for next stream + # Reset current_idx="" current_type="" current_name="" @@ -230,63 +217,53 @@ if [[ "$MEDIA_TYPE" == "video" || "$MEDIA_TYPE" == "audio" ]]; then done <<< "$STREAMS_RAW" # --- Audio track selection --- - # Convert checklist entries to (id label on/off) pairs for kdialog - audio_pairs=() - for (( a=0; a<${#audio_entries[@]}; a+=3 )); do - audio_pairs+=("${audio_entries[$a]}" "${audio_entries[$((a+1))]}" "${audio_entries[$((a+2))]:-on}") - done - - num_audio=$(( ${#audio_real_idx[@]} )) - if (( num_audio == 0 )); then - : # no audio streams, nothing to map - elif (( num_audio == 1 )); then - # Single audio stream — auto-select it - map_audio=("${audio_real_idx[0]}") + num_audio=${#audio_absolute_indices[@]} + if (( num_audio == 1 )); then + map_audio=("${audio_absolute_indices[0]}") elif (( num_audio > 1 )); then - # Multiple audio streams — let user pick + audio_pairs=() + for (( i=0; i 1 )); then + sub_pairs=() + for (( i=0; i= 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 fi @@ -360,7 +339,7 @@ ask_video_quality() { local audio_input audio_input=$(kdialog --title "Transmutate — Video Quality" --inputbox \ "${audio_label} ${audio_range}:" \ - "$audio_default") || quit + "$audio_default") || exit # Empty → accept default if [[ -z "$audio_input" ]]; then @@ -377,10 +356,10 @@ ask_video_quality() { --msgbox "Please enter a valid integer." 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. AUDIO_QUALITY="" case "$MEDIA_TYPE" in @@ -399,13 +378,12 @@ case "$MEDIA_TYPE" in QUALITY=$(ask_quality image 50) # 0-100 scale, default 50 else # Video → video: combined dialog for both video CRF and audio quality - read -r VIDEO_QUALITY AUDIO_QUALITY <<< $(ask_video_quality "$TARGET") + read -r VIDEO_QUALITY AUDIO_QUALITY <<< "$(ask_video_quality "$TARGET")" QUALITY="$VIDEO_QUALITY" fi ;; esac -echo "Quality=$QUALITY" -echo "AudioQuality=$AUDIO_QUALITY" +echo "Quality=$QUALITY, AudioQuality=${AUDIO_QUALITY:-unset}" # GIF & WebP get extra animation options if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then @@ -420,7 +398,7 @@ if [[ "$TARGET" == "gif" || "$TARGET" == "webp" ]]; then CHECKS=$(kdialog --title "Transmutate — Animation Settings" --checklist \ "Options for: $INPUT_BASENAME" \ "1" "Animated" "on" \ - "2" "Looping" "off") || quit + "2" "Looping" "off") || exit # Kdialog returns space-separated quoted IDs, e.g. "1" "2" or just "2" # Check if "2" (Looping) is in the selection if [[ -n "$CHECKS" ]] && [[ "$CHECKS" == *"2"* ]]; then @@ -484,10 +462,11 @@ echo "Output: $OUTPUT_FILE" # ─── 8. Build ffmpeg command ──────────────────────────────────────────────────── # For images/audio: user-facing 0-100, ffmpeg q-scale is inverted (lower=better). -# For video: user enters CRF directly (0=best, 51=worst) — no inversion needed. +# For video: user-facing CRF is 51-0 (higher=better), inverted to ffmpeg 0-51 (lower=better). Q="" if [[ "$MEDIA_TYPE" == "video" ]]; then - CRF="$QUALITY" # CRF used directly + # Already inverted in ask_video_quality or ask_quality, use directly + : else # Invert: user 100 (best) → ffmpeg q=1 (best), user 0 (worst) → ffmpeg q=100 (worst) Q=$(( 100 - QUALITY )) @@ -525,24 +504,40 @@ if [[ "$MEDIA_TYPE" == "video" && -n "$AUDIO_QUALITY" ]]; then esac fi -# Build -map arguments for video streams -# -map 0:v:0 = always map first video stream -# -map 0:a:idx = map selected audio streams (stream-copy when no -c:a) -# -map 0:s:idx = map selected subtitle streams -# When AQ_ARG is set → add audio map + -c:a forces re-encode -# When AQ_ARG is empty → only add audio map for stream-copy +# ── Pre-compute subtitle codec argument ────────────────────────── +SQ_ARG="" +if (( ${#map_sub[@]} > 0 )); then + case "$TARGET" in + mp4|mov) + SQ_ARG="-c:s mov_text" + ;; + mkv) + SQ_ARG="-c:s copy" + ;; + webm) + SQ_ARG="-c:s webvtt" + ;; + avi) + SQ_ARG="-c:s copy" + ;; + esac +fi + +# Build -map arguments for streams +# We use absolute indices (e.g., -map 0:1) instead of category indices (e.g., -map 0:a:0) MAP_ARGS="-map 0:v:0" if (( ${#map_audio[@]} > 0 )); then for ai in "${map_audio[@]}"; do - MAP_ARGS+=" -map 0:a:$ai" + MAP_ARGS+=" -map 0:$ai" done elif [[ -n "$AQ_ARG" ]]; then # Fallback if no audio selected but we need audio MAP_ARGS+=" -map 0:a:0" fi + if (( ${#map_sub[@]} > 0 )); then for si in "${map_sub[@]}"; do - MAP_ARGS+=" -map 0:s:$si" + MAP_ARGS+=" -map 0:$si" done fi @@ -581,7 +576,6 @@ case "$MEDIA_TYPE" in # Map quality (0-100) to palette colors (3-256) # Minimum 3 because ffmpeg doesn't support max_colors=2 with reserve_transparent IMG_PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 )) - echo "DEBUG: image→GIF fallback QUALITY=$QUALITY IMG_PALETTE_COLORS=$IMG_PALETTE_COLORS" if $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vf 'fps=$FPS,scale=$SCALE,split[s0][s1];[s0]palettegen=max_colors=$IMG_PALETTE_COLORS:reserve_transparent=1[p];[s1][p]paletteuse=dither=sierra2_4a' -loop 0 '${OUTPUT_FILE}'" else @@ -623,23 +617,23 @@ case "$MEDIA_TYPE" in ;; mp4) # Animated image → MP4: use libwebp/ffmpeg to extract frames, re-encode as h264 - CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" + CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" ;; mkv) # Animated image → MKV - CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -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) # 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) # 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) # 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 ;; @@ -647,19 +641,19 @@ case "$MEDIA_TYPE" in video) case "$TARGET" in mp4) - CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" + CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG $SQ_ARG -crf $QUALITY -pix_fmt yuv420p -movflags +faststart '${OUTPUT_FILE}'" ;; mkv) - CMD="ffmpeg -y -v error -i '${INPUT_FILE}' $MAP_ARGS $AQ_ARG -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) - 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) - 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) - 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) # 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_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "${INPUT_FILE}" 2>/dev/null || echo "0") SCALE="${SRC_W}:${SRC_H}:flags=lanczos" - # If fps is a fraction like 24000/1001, keep as-is; otherwise use as integer - if [[ "$SRC_FPS" == */* ]]; then - FPS="$SRC_FPS" - else - FPS="$SRC_FPS" - fi + FPS="$SRC_FPS" # Map quality (0-100) to palette generation colors (3-256) # Minimum 3 colors because ffmpeg doesn't support max_colors=2 with reserve_transparent PALETTE_COLORS=$(( QUALITY * 253 / 100 + 3 )) @@ -689,7 +678,6 @@ case "$MEDIA_TYPE" in else DITHER="none" fi - echo "DEBUG: video→GIF QUALITY=$QUALITY PALETTE_COLORS=$PALETTE_COLORS DITHER=$DITHER" if $LOOPING; then CMD="ffmpeg -y -v error -i '${INPUT_FILE}' -vf 'fps=$FPS,scale=$SCALE,split[s0][s1];[s0]palettegen=max_colors=$PALETTE_COLORS:reserve_transparent=1[p];[s1][p]paletteuse=dither=$DITHER' -loop 0 '${OUTPUT_FILE}'" else @@ -714,7 +702,7 @@ case "$MEDIA_TYPE" in if (( ${#map_audio[@]} > 0 )); then AUDIO_MAP="" for ai in "${map_audio[@]}"; do - AUDIO_MAP+="-map 0:a:$ai " + AUDIO_MAP+="-map 0:$ai " done fi @@ -755,10 +743,7 @@ esac # Skipped for WebP→GIF since ImageMagick handles those better. USE_MAGICK=false -if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "gif" ]] && has_magick; then - USE_MAGICK=true -fi -if [[ "$MEDIA_TYPE" == "image" && "$TARGET" == "webp" ]] && has_magick; then +if [[ "$MEDIA_TYPE" == "image" ]] && { [[ "$TARGET" == "gif" ]] || [[ "$TARGET" == "webp" ]]; } && has_magick; then USE_MAGICK=true fi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36dab53 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +customtkinter +Pillow +ffmpeg-python diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..caf6f13 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for transmutate_app.""" diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..d9220e0 --- /dev/null +++ b/tests/test_engine.py @@ -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() diff --git a/transmutate.py b/transmutate.py new file mode 100644 index 0000000..e7893cb --- /dev/null +++ b/transmutate.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Transmutate — Media Conversion GUI. + +Usage: + python transmutate.py + +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 ") + 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() diff --git a/transmutate_app/__init__.py b/transmutate_app/__init__.py new file mode 100644 index 0000000..e90d365 --- /dev/null +++ b/transmutate_app/__init__.py @@ -0,0 +1 @@ +"""transmutate_app — Media Conversion GUI package.""" diff --git a/transmutate_app/__main__.py b/transmutate_app/__main__.py new file mode 100644 index 0000000..1686219 --- /dev/null +++ b/transmutate_app/__main__.py @@ -0,0 +1,19 @@ +"""Allow running transmutate_app as a module: python -m transmutate_app .""" + +import sys +import os +from .gui import open_file + + +def main(): + """CLI entry point for ``python -m transmutate_app ``.""" + if len(sys.argv) < 2: + print("Usage: python -m transmutate_app ") + 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) diff --git a/transmutate_app/engine/__init__.py b/transmutate_app/engine/__init__.py new file mode 100644 index 0000000..7484adb --- /dev/null +++ b/transmutate_app/engine/__init__.py @@ -0,0 +1 @@ +"""Engine module — FFmpeg and ImageMagick command builders.""" diff --git a/transmutate_app/engine/ffmpeg_engine.py b/transmutate_app/engine/ffmpeg_engine.py new file mode 100644 index 0000000..d41800c --- /dev/null +++ b/transmutate_app/engine/ffmpeg_engine.py @@ -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:`` — 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:`` — 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 diff --git a/transmutate_app/gui.py b/transmutate_app/gui.py new file mode 100644 index 0000000..121bc9e --- /dev/null +++ b/transmutate_app/gui.py @@ -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()