Files
Transmutate/transmutate_app/gui.py
2026-06-01 02:06:49 +03:00

970 lines
36 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()