♻ | New project structure

This commit is contained in:
2026-06-01 02:06:49 +03:00
parent 192a02df45
commit 75808cfed8
13 changed files with 2114 additions and 150 deletions

View File

@@ -0,0 +1 @@
"""transmutate_app — Media Conversion GUI package."""

View 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)

View File

@@ -0,0 +1 @@
"""Engine module — FFmpeg and ImageMagick command builders."""

View 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 (0100) to a CRF value (051).
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
View 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()