♻ | New project structure
This commit is contained in:
969
transmutate_app/gui.py
Normal file
969
transmutate_app/gui.py
Normal file
@@ -0,0 +1,969 @@
|
||||
"""Main GUI module for Transmutate — CustomTkinter application."""
|
||||
|
||||
# flake8: noqa: F401 (imports used by TransmutateApp, not exposed at module level)
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import customtkinter as ctk
|
||||
|
||||
from .engine.ffmpeg_engine import (
|
||||
detect_mime,
|
||||
detect_media_type,
|
||||
probe_file,
|
||||
has_ffmpeg,
|
||||
preflight_check,
|
||||
run_command,
|
||||
_build_image_command,
|
||||
_build_video_command,
|
||||
_build_audio_command,
|
||||
_build_video_gif,
|
||||
_build_video_webp,
|
||||
StreamInfo,
|
||||
)
|
||||
|
||||
|
||||
# Supported output formats
|
||||
IMAGE_FORMATS = {
|
||||
"png": "PNG",
|
||||
"jpg": "JPG",
|
||||
"webp": "WebP",
|
||||
"avif": "AVIF",
|
||||
}
|
||||
|
||||
ANIMATED_IMAGE_FORMATS = {
|
||||
**IMAGE_FORMATS,
|
||||
"gif": "GIF",
|
||||
"mp4": "MP4",
|
||||
"mkv": "MKV",
|
||||
"webm": "WebM",
|
||||
"avi": "AVI",
|
||||
"mov": "MOV",
|
||||
}
|
||||
|
||||
VIDEO_FORMATS = {
|
||||
"mp4": "MP4",
|
||||
"mkv": "MKV",
|
||||
"webm": "WebM",
|
||||
"avi": "AVI",
|
||||
"mov": "MOV",
|
||||
"gif": "GIF",
|
||||
"webp": "WebP (animated)",
|
||||
}
|
||||
|
||||
AUDIO_FORMATS = {
|
||||
"mp3": "MP3",
|
||||
"flac": "FLAC",
|
||||
"wav": "WAV",
|
||||
"ogg": "OGG",
|
||||
"m4a": "M4A",
|
||||
"aac": "AAC",
|
||||
}
|
||||
|
||||
class ConversionConfig:
|
||||
"""Dataclass-like holder for all user-selected conversion parameters."""
|
||||
|
||||
def __init__(self):
|
||||
self.source_file: str = ""
|
||||
self.media_type: str = "" # image, video, audio
|
||||
self.mime_type: str = ""
|
||||
self.is_animated: bool = False
|
||||
self.target_format: str = "" # png, mp4, mp3, etc.
|
||||
self.audio_streams: list[int] = [] # ffprobe stream indices
|
||||
self.sub_streams: list[int] = [] # ffprobe stream indices
|
||||
self.quality: int = 85 # user-facing 0-100
|
||||
self.audio_quality: int = 85 # per-output-type scale
|
||||
self.loop: bool = False
|
||||
self.output_file: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CustomTkinter Dialog Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _die(root: ctk.CTk, message: str) -> None:
|
||||
"""Show an error dialog, then destroy the root window and exit."""
|
||||
_show_message(root, "Transmutate — Error", message, kind="error")
|
||||
root.destroy()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _show_message(
|
||||
parent: ctk.CTk, title: str, message: str,
|
||||
kind: str = "info",
|
||||
) -> None:
|
||||
"""Show a CustomTkinter modal dialog with a single OK button."""
|
||||
dialog = ctk.CTkToplevel(parent)
|
||||
dialog.title(title)
|
||||
dialog.resizable(False, False)
|
||||
|
||||
# Center the dialog
|
||||
parent.update_idletasks()
|
||||
pw, ph = parent.winfo_width(), parent.winfo_height()
|
||||
px, py = parent.winfo_x(), parent.winfo_y()
|
||||
dw, dh = 420, 240
|
||||
dx = px + (pw - dw) // 2
|
||||
dy = py + (ph - dh) // 2
|
||||
dialog.geometry(f"{dw}x{dh}+{dx}+{dy}")
|
||||
|
||||
# Icon based on kind
|
||||
icons = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "question": "❓"}
|
||||
icon = icons.get(kind, "ℹ️")
|
||||
|
||||
# Theme based on kind
|
||||
themes = {
|
||||
"info": ("#0ea5e9", "#0284c7", "#0369a1"),
|
||||
"warning": ("#f59e0b", "#d97706", "#b45309"),
|
||||
"error": ("#ef4444", "#dc2626", "#b91c1c"),
|
||||
"question":("#8b5cf6", "#7c3aed", "#6d28d9"),
|
||||
}
|
||||
bg_color, btn_color, btn_hover = themes.get(kind, themes["info"])
|
||||
|
||||
body = ctk.CTkFrame(dialog, fg_color="transparent")
|
||||
body.pack(fill="both", padx=20, pady=16)
|
||||
|
||||
icon_lbl = ctk.CTkLabel(
|
||||
body, text=icon, font=ctk.CTkFont(size=20),
|
||||
fg_color="transparent", text_color=bg_color,
|
||||
)
|
||||
icon_lbl.pack()
|
||||
|
||||
ctk.CTkLabel(
|
||||
body, text=message, wraplength=360,
|
||||
font=ctk.CTkFont(size=13), justify="center",
|
||||
).pack(pady=(8, 8))
|
||||
|
||||
btn_frame = ctk.CTkFrame(body, fg_color="transparent")
|
||||
btn_frame.pack(pady=(0, 8))
|
||||
|
||||
def on_close():
|
||||
dialog.destroy()
|
||||
|
||||
close_btn = ctk.CTkButton(
|
||||
btn_frame, text="OK", width=80,
|
||||
fg_color=btn_color,
|
||||
hover_color=btn_hover,
|
||||
text_color="#ffffff",
|
||||
font=ctk.CTkFont(size=13, weight="bold"),
|
||||
corner_radius=8,
|
||||
command=on_close,
|
||||
)
|
||||
close_btn.pack()
|
||||
|
||||
# Must be visible before grab_set so grab doesn't fail
|
||||
dialog.update_idletasks()
|
||||
dialog.grab_set()
|
||||
|
||||
|
||||
def _ask_question(
|
||||
parent: ctk.CTk, title: str, message: str,
|
||||
) -> bool:
|
||||
"""Show a modal dialog asking the user to overwrite or rename.
|
||||
|
||||
Returns ``True`` for overwrite, ``False`` for rename.
|
||||
"""
|
||||
dialog = ctk.CTkToplevel(parent)
|
||||
dialog.title(title)
|
||||
dialog.resizable(False, False)
|
||||
|
||||
# Center the dialog
|
||||
parent.update_idletasks()
|
||||
pw, ph = parent.winfo_width(), parent.winfo_height()
|
||||
px, py = parent.winfo_x(), parent.winfo_y()
|
||||
dialog.geometry("360x120" + f"+{px + (pw - 360) // 2}+{py + (ph - 120) // 2}")
|
||||
|
||||
# Message label — directly on the dialog, no wrapper frames
|
||||
msg_lbl = ctk.CTkLabel(
|
||||
dialog, text=message, wraplength=320,
|
||||
font=ctk.CTkFont(size=13), justify="center",
|
||||
)
|
||||
msg_lbl.pack(pady=(20, 8))
|
||||
|
||||
result = {"value": False}
|
||||
|
||||
btn_row = ctk.CTkFrame(dialog, fg_color="transparent")
|
||||
btn_row.pack(pady=(0, 20))
|
||||
|
||||
def on_overwrite():
|
||||
result["value"] = True
|
||||
dialog.destroy()
|
||||
|
||||
def on_rename():
|
||||
result["value"] = False
|
||||
dialog.destroy()
|
||||
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Overwrite", width=100,
|
||||
fg_color="#059669",
|
||||
hover_color="#047857",
|
||||
text_color="#ffffff",
|
||||
font=ctk.CTkFont(size=13, weight="bold"),
|
||||
corner_radius=6,
|
||||
command=on_overwrite,
|
||||
).pack(side="left", padx=6)
|
||||
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Rename", width=100,
|
||||
fg_color="#6b7280",
|
||||
hover_color="#4b5563",
|
||||
text_color="#ffffff",
|
||||
font=ctk.CTkFont(size=13, weight="bold"),
|
||||
corner_radius=6,
|
||||
command=on_rename,
|
||||
).pack(side="left", padx=6)
|
||||
|
||||
dialog.update_idletasks()
|
||||
dialog.grab_set()
|
||||
return result["value"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main Application
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TransmutateApp:
|
||||
"""Main CustomTkinter application for Transmutate."""
|
||||
|
||||
def __init__(self, root: ctk.CTk, filepath: str):
|
||||
self.root = root
|
||||
self.config = ConversionConfig()
|
||||
self.config.source_file = os.path.abspath(filepath)
|
||||
|
||||
# Basic validation
|
||||
if not os.path.isfile(self.config.source_file):
|
||||
_die(root, f"File does not exist: {filepath}")
|
||||
|
||||
self.config.mime_type = detect_mime(self.config.source_file)
|
||||
self.config.media_type = detect_media_type(self.config.mime_type)
|
||||
|
||||
if not self.config.media_type:
|
||||
_die(root, f"Unsupported file type: {self.config.mime_type}")
|
||||
|
||||
if not has_ffmpeg():
|
||||
_die(root, "ffmpeg is not installed or not in PATH")
|
||||
|
||||
probe = probe_file(self.config.source_file, self.config.mime_type)
|
||||
self.config.is_animated = probe.is_animated
|
||||
self.streams = list(probe.streams) # copy to avoid mutation
|
||||
|
||||
base = os.path.basename(self.config.source_file)
|
||||
self._input_name, _ = os.path.splitext(base)
|
||||
self._input_dir = os.path.dirname(self.config.source_file)
|
||||
|
||||
# Build UI
|
||||
self.root.title("Transmutate")
|
||||
self._build_ui()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# UI Construction
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
"""Build the main application window."""
|
||||
self.root.geometry("720x720")
|
||||
self.root.minsize(480, 400)
|
||||
|
||||
main = ctk.CTkFrame(self.root)
|
||||
main.pack(fill="both", expand=True, padx=16, pady=16)
|
||||
|
||||
self._build_info_header(main)
|
||||
self._build_category_buttons(main)
|
||||
|
||||
self.options_frame = ctk.CTkScrollableFrame(
|
||||
main, fg_color="transparent",
|
||||
label_text="Options", label_font=ctk.CTkFont(size=11, weight="bold"),
|
||||
)
|
||||
self.options_frame.pack(fill="both", expand=True, pady=(8, 4))
|
||||
|
||||
self._build_bottom(main)
|
||||
|
||||
def _build_info_header(self, parent):
|
||||
"""Display file info (name, media type, extension) at the top."""
|
||||
info_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
info_frame.pack(fill="x", pady=(0, 8))
|
||||
|
||||
media_label = self.config.media_type.upper()
|
||||
ext = os.path.splitext(self.config.source_file)[1].lstrip(".").upper()
|
||||
self.info_label = ctk.CTkLabel(
|
||||
info_frame,
|
||||
text=f"📁 {os.path.basename(self.config.source_file)} "
|
||||
f"[{media_label} · {ext}]",
|
||||
font=ctk.CTkFont(family="", size=10),
|
||||
)
|
||||
self.info_label.pack(anchor="center")
|
||||
|
||||
if self.config.is_animated:
|
||||
ctk.CTkLabel(info_frame, text=" ⚡ Animated", text_color="#b87333").pack(anchor="center")
|
||||
|
||||
def _build_category_buttons(self, parent):
|
||||
"""Source-to-target type selector buttons (Image / Video / Audio)."""
|
||||
btn_frame = ctk.CTkFrame(parent)
|
||||
btn_frame.pack(fill="x", pady=(0, 4))
|
||||
|
||||
ctk.CTkLabel(btn_frame, text="Convert to:", font=ctk.CTkFont(family="", size=11)).pack()
|
||||
|
||||
target_types = {"image": "Image", "video": "Video", "audio": "Audio"}
|
||||
current = self.config.media_type
|
||||
|
||||
row_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
|
||||
row_frame.pack()
|
||||
|
||||
for key, label in target_types.items():
|
||||
if key == current:
|
||||
state = "normal"
|
||||
elif self.config.media_type == "video" and key == "image":
|
||||
state = "disabled"
|
||||
elif self.config.media_type == "image" and key in ("video", "audio"):
|
||||
state = "disabled"
|
||||
elif self.config.media_type == "audio" and key in ("video", "image"):
|
||||
state = "disabled"
|
||||
else:
|
||||
state = "normal"
|
||||
btn = ctk.CTkButton(
|
||||
row_frame, text=label,
|
||||
command=lambda k=key: self._on_target_type(k),
|
||||
state=state,
|
||||
)
|
||||
btn.pack(side="left", padx=(4, 4))
|
||||
|
||||
def _build_bottom(self, parent):
|
||||
"""Conversion button anchored at the bottom."""
|
||||
bottom_container = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
bottom_container.pack(fill="x", side="bottom", pady=(4, 12))
|
||||
bottom_container.pack_propagate(False)
|
||||
|
||||
self.transform_btn = ctk.CTkButton(
|
||||
bottom_container, text="Transmutate", command=self._on_transform,
|
||||
font=ctk.CTkFont(family="", size=12, weight="bold"),
|
||||
height=36,
|
||||
width=160,
|
||||
)
|
||||
self.transform_btn.pack()
|
||||
|
||||
# Disable until user sets options
|
||||
self.transform_btn.configure(state="disabled")
|
||||
|
||||
# Dynamic option panels
|
||||
|
||||
def _on_target_type(self, target_type: str):
|
||||
"""Switch to a new conversion target panel."""
|
||||
self.config.quality = 85
|
||||
self.config.audio_quality = 85
|
||||
self.config.loop = False
|
||||
self.config.audio_streams = []
|
||||
self.config.sub_streams = []
|
||||
self.transform_btn.configure(state="disabled")
|
||||
|
||||
for w in self.options_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
# Clear stale audio-quality widget references
|
||||
for attr in ("_audio_quality_frame", "_audio_quality_slider",
|
||||
"_audio_quality_var", "_audio_quality_label",
|
||||
"_audio_quality_title_label", "_audio_quality_widgets"):
|
||||
if hasattr(self, attr):
|
||||
delattr(self, attr)
|
||||
|
||||
dispatch = {
|
||||
("image", "image"): self._build_image_image_panel,
|
||||
("image", "video"): self._build_image_video_panel,
|
||||
("image", "audio"): self._build_image_audio_panel,
|
||||
("video", "image"): self._build_video_image_panel,
|
||||
("video", "video"): self._build_video_video_panel,
|
||||
("video", "audio"): self._build_video_audio_panel,
|
||||
("audio", "image"): self._build_audio_image_panel,
|
||||
("audio", "video"): self._build_audio_video_panel,
|
||||
("audio", "audio"): self._build_audio_audio_panel,
|
||||
}
|
||||
fn = dispatch.get((self.config.media_type, target_type))
|
||||
if fn:
|
||||
fn()
|
||||
if hasattr(self, "_format_var"):
|
||||
self.config.target_format = self._format_var.get()
|
||||
self.transform_btn.configure(state="normal")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helper: create options panel widgets
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_radio_group(self, parent, formats: dict[str, str], default_key: str):
|
||||
"""Add format radio buttons inside *parent*. Returns the ``StringVar``."""
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
frame.pack(fill="x", pady=(4, 0))
|
||||
|
||||
ctk.CTkLabel(frame, text="Format:", font=ctk.CTkFont(family="", size=11, weight="bold")).pack(anchor="center")
|
||||
|
||||
radios_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
radios_frame.pack(fill="x", pady=(2, 0))
|
||||
|
||||
selected = ctk.StringVar(value=default_key)
|
||||
|
||||
for key, label in formats.items():
|
||||
rb = ctk.CTkRadioButton(
|
||||
radios_frame, text=label, value=key,
|
||||
variable=selected, command=self._on_format_change,
|
||||
)
|
||||
rb.pack(side="left", padx=(0, 8))
|
||||
|
||||
# Track it on self for later reading
|
||||
self._format_var = selected
|
||||
return selected
|
||||
|
||||
def _add_quality_slider(self, parent, default: int = 85,
|
||||
label_text: str = "Quality: ",
|
||||
crf_max: int = 0):
|
||||
"""Add a quality slider and label, returning the parent ``Frame``.
|
||||
|
||||
When *crf_max* > 0 (CRF mode) the slider runs *crf_max .. 0* so
|
||||
the right side is best quality (lowest CRF). *default* is on the
|
||||
user-facing scale (0 – crf_max).
|
||||
|
||||
When *crf_max* == 0 the slider runs 0 – 100 (higher = better).
|
||||
"""
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
frame.pack(fill="x", pady=(4, 0))
|
||||
|
||||
# Use grid for everything in this frame
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
qty_label_text = ctk.CTkLabel(frame, text=label_text, font=ctk.CTkFont(family="", size=11, weight="bold"))
|
||||
qty_label_text.grid(row=0, column=0, columnspan=2, pady=(0, 2))
|
||||
|
||||
qty = ctk.IntVar(value=default)
|
||||
|
||||
# CRF: slider right → best quality (lower CRF), i.e. crf_max down to 0
|
||||
# Standard: slider right → better quality, i.e. 0 up to 100
|
||||
if crf_max > 0:
|
||||
slider = ctk.CTkSlider(
|
||||
frame, from_=crf_max, to=0,
|
||||
variable=qty,
|
||||
command=self._on_quality_scale_clamped,
|
||||
)
|
||||
else:
|
||||
slider = ctk.CTkSlider(
|
||||
frame, from_=0, to=100,
|
||||
variable=qty,
|
||||
command=self._on_quality_scale_clamped,
|
||||
)
|
||||
slider.grid(row=1, column=0, sticky="ew", columnspan=2, padx=(0, 8))
|
||||
|
||||
self._quality_display_label = ctk.CTkLabel(frame, text=str(default), width=40, anchor="e")
|
||||
self._quality_display_label.grid(row=1, column=1)
|
||||
|
||||
self._quality_var = qty
|
||||
self._quality_frame = frame
|
||||
self._quality_slider = slider
|
||||
self._quality_mode = "standard"
|
||||
self._crf_max = crf_max
|
||||
return frame
|
||||
|
||||
def _on_quality_scale_clamped(self, val):
|
||||
"""Clamp slider value to bounds, then update display."""
|
||||
v = int(float(val))
|
||||
qty_var = self._quality_var
|
||||
crf_max = getattr(self, "_crf_max", 0)
|
||||
|
||||
if crf_max > 0:
|
||||
# CRF mode: val is in [crf_max, 0], clamp to that range
|
||||
if v < 0:
|
||||
v = 0
|
||||
elif v > crf_max:
|
||||
v = crf_max
|
||||
qty_var.set(v)
|
||||
self._quality_display_label.configure(text=str(v))
|
||||
else:
|
||||
# Standard mode: val is in [0, 100], clamp to that range
|
||||
if v < 0:
|
||||
v = 0
|
||||
elif v > 100:
|
||||
v = 100
|
||||
qty_var.set(v)
|
||||
self._quality_display_label.configure(text=str(v))
|
||||
|
||||
def _add_audio_quality_slider(self, parent, default: int = 85):
|
||||
"""Add an audio-quality slider for video output, returning the parent ``Frame``."""
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
frame.pack(fill="x", pady=(4, 0))
|
||||
|
||||
# Use grid for everything in this frame — no pack/grid mixing.
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
title_label = ctk.CTkLabel(frame, text="Audio Quality:", font=ctk.CTkFont(family="", size=11, weight="bold"))
|
||||
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 2))
|
||||
|
||||
qty = ctk.IntVar(value=default)
|
||||
|
||||
slider = ctk.CTkSlider(
|
||||
frame, from_=0, to=100,
|
||||
variable=qty,
|
||||
command=lambda val: self._on_audio_quality_scale_clamped(val, qty),
|
||||
)
|
||||
slider.grid(row=1, column=0, sticky="ew", columnspan=2, padx=(0, 8))
|
||||
|
||||
self._audio_quality_label = ctk.CTkLabel(frame, text=str(default), width=40, anchor="e")
|
||||
self._audio_quality_label.grid(row=1, column=1)
|
||||
|
||||
self._audio_quality_var = qty
|
||||
self._audio_quality_frame = frame
|
||||
self._audio_quality_slider = slider
|
||||
self._audio_quality_title_label = title_label
|
||||
self._audio_quality_widgets = (title_label, slider, self._audio_quality_label)
|
||||
return frame
|
||||
|
||||
def _on_audio_quality_scale_clamped(self, val, qty_var):
|
||||
"""Clamp audio-quality slider value to 0-100 bounds."""
|
||||
v = int(float(val))
|
||||
if v < 0:
|
||||
v = 0
|
||||
elif v > 100:
|
||||
v = 100
|
||||
qty_var.set(v)
|
||||
self._audio_quality_label.configure(text=str(v))
|
||||
|
||||
def _add_loop_checkbox(self, parent):
|
||||
"""Add a loop checkbox, returning the ``BooleanVar``."""
|
||||
cb_frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
cb_frame.pack(fill="x", pady=(2, 0))
|
||||
|
||||
var = ctk.BooleanVar(value=False)
|
||||
cb = ctk.CTkCheckBox(cb_frame, text="Loop output", variable=var)
|
||||
cb.pack(anchor="center")
|
||||
self._loop_var = var
|
||||
self._loop_frame = cb_frame
|
||||
return var
|
||||
|
||||
def _add_stream_selection(self, parent, streams: list[StreamInfo], stream_type: str = ""):
|
||||
"""Add checkboxes for selecting audio/subtitle streams."""
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
frame = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
frame.pack(fill="x", pady=(4, 0))
|
||||
|
||||
header = f"Select {stream_type}" if stream_type else "Streams"
|
||||
ctk.CTkLabel(frame, text=f"{header}:", font=ctk.CTkFont(family="", size=11, weight="bold")).pack(anchor="center")
|
||||
|
||||
checkboxes_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
checkboxes_frame.pack(fill="x", pady=(2, 0))
|
||||
|
||||
vars_dict = {}
|
||||
for stream in streams:
|
||||
var = ctk.BooleanVar(value=True) # default: all selected
|
||||
cb = ctk.CTkCheckBox(
|
||||
checkboxes_frame, text=stream.label(), variable=var,
|
||||
)
|
||||
cb.pack(anchor="center")
|
||||
vars_dict[stream.index] = var
|
||||
|
||||
self._stream_vars = vars_dict
|
||||
return vars_dict
|
||||
|
||||
def _maybe_update_transform(self):
|
||||
"""Enable/disable the conversion button based on current selections."""
|
||||
has_format = hasattr(self, "_format_var") and self.config.target_format
|
||||
self.transform_btn.configure(state="normal" if has_format else "disabled")
|
||||
|
||||
def _on_format_change(self):
|
||||
"""Handle format selection change."""
|
||||
fmt = self._format_var.get()
|
||||
self.config.target_format = fmt
|
||||
self._maybe_update_transform()
|
||||
self._maybe_update_quality_controls()
|
||||
|
||||
def _maybe_update_quality_controls(self):
|
||||
"""Show/hide quality controls based on the current format."""
|
||||
fmt = getattr(self, "_format_var", None)
|
||||
if fmt is None:
|
||||
return
|
||||
fmt = fmt.get()
|
||||
|
||||
# Determine which quality mode to show
|
||||
show_crf = fmt in VIDEO_FORMATS and fmt not in ("gif", "webp")
|
||||
show_loop = fmt in ("gif", "webp")
|
||||
|
||||
# Show/hide quality slider label dynamically
|
||||
if hasattr(self, "_quality_var"):
|
||||
# Find the quality slider's label and update text
|
||||
if hasattr(self, "_quality_frame"):
|
||||
for child in self._quality_frame.winfo_children():
|
||||
if isinstance(child, ctk.CTkLabel):
|
||||
if show_crf:
|
||||
child.configure(text="Video CRF (0-51): ")
|
||||
elif show_loop:
|
||||
child.configure(text="Quality (0-100): ")
|
||||
else:
|
||||
child.configure(text="Quality (0-100): ")
|
||||
break
|
||||
|
||||
# Toggle quality slider range and label text
|
||||
if hasattr(self, "_quality_var") and hasattr(self, "_quality_slider") and hasattr(self, "_quality_display_label"):
|
||||
# Determine the target quality mode
|
||||
if show_crf:
|
||||
target_mode = "crf"
|
||||
elif show_loop:
|
||||
target_mode = "loop"
|
||||
else:
|
||||
target_mode = "standard"
|
||||
|
||||
# Only reset slider value when actually changing modes
|
||||
mode_changed = target_mode != getattr(self, "_quality_mode", None)
|
||||
|
||||
if mode_changed:
|
||||
if show_crf:
|
||||
# Slider already reports 0..crf_max directly in CRF mode
|
||||
self._quality_var.set(23)
|
||||
self._quality_display_label.configure(text="23")
|
||||
elif show_loop:
|
||||
self._quality_var.set(50)
|
||||
self._quality_display_label.configure(text="50")
|
||||
else:
|
||||
self._quality_var.set(85)
|
||||
self._quality_display_label.configure(text="85")
|
||||
|
||||
self._quality_mode = target_mode
|
||||
|
||||
# Show/hide audio quality slider
|
||||
if hasattr(self, "_audio_quality_frame"):
|
||||
if show_crf:
|
||||
self._audio_quality_frame.pack(fill="x", pady=(4, 0))
|
||||
else:
|
||||
self._audio_quality_frame.pack_forget()
|
||||
|
||||
# Show/hide loop checkbox
|
||||
if hasattr(self, "_loop_var") and hasattr(self, "_loop_frame"):
|
||||
if show_loop:
|
||||
self._loop_frame.pack()
|
||||
else:
|
||||
self._loop_frame.pack_forget()
|
||||
|
||||
# Unified panel builders
|
||||
|
||||
def _build_image_image_panel(self):
|
||||
formats = ANIMATED_IMAGE_FORMATS if self.config.is_animated else IMAGE_FORMATS
|
||||
self._add_radio_group(self.options_frame, formats, "png")
|
||||
self._add_quality_slider(self.options_frame, default=85,
|
||||
label_text="Quality (0-100): ")
|
||||
self._add_loop_checkbox(self.options_frame)
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_image_video_panel(self):
|
||||
self._add_radio_group(self.options_frame, {
|
||||
"mp4": "MP4", "mkv": "MKV", "webm": "WebM",
|
||||
"avi": "AVI", "mov": "MOV",
|
||||
}, "mp4")
|
||||
self._add_quality_slider(self.options_frame, default=23,
|
||||
label_text="Video CRF (0-51): ",
|
||||
crf_max=51)
|
||||
self._add_audio_quality_slider(self.options_frame, default=85)
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_image_audio_panel(self):
|
||||
self._add_radio_group(self.options_frame, AUDIO_FORMATS, "mp3")
|
||||
self._add_quality_slider(self.options_frame, default=85,
|
||||
label_text="Quality (0-100): ")
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_video_image_panel(self):
|
||||
self._add_radio_group(self.options_frame, {
|
||||
"png": "PNG", "jpg": "JPG", "webp": "WebP", "avif": "AVIF",
|
||||
}, "png")
|
||||
self._add_quality_slider(self.options_frame, default=85,
|
||||
label_text="Quality (0-100): ")
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_video_video_panel(self):
|
||||
# Audio streams
|
||||
audio_streams = [s for s in self.streams if s.codec_type == "audio"]
|
||||
if len(audio_streams) == 0:
|
||||
pass
|
||||
elif len(audio_streams) == 1:
|
||||
self.config.audio_streams = [audio_streams[0].index]
|
||||
else:
|
||||
self._add_stream_selection(
|
||||
self.options_frame, audio_streams, "audio",
|
||||
)
|
||||
|
||||
# Subtitle streams
|
||||
sub_streams = [s for s in self.streams if s.codec_type == "subtitle"]
|
||||
if len(sub_streams) == 1:
|
||||
self.config.sub_streams = [sub_streams[0].index]
|
||||
elif len(sub_streams) > 1:
|
||||
self._add_stream_selection(
|
||||
self.options_frame, sub_streams, "subtitle",
|
||||
)
|
||||
|
||||
# Regular video formats
|
||||
self._add_radio_group(self.options_frame, {
|
||||
"mp4": "MP4", "mkv": "MKV", "webm": "WebM",
|
||||
"avi": "AVI", "mov": "MOV",
|
||||
}, "mp4")
|
||||
|
||||
# GIF / WebP on a separate line below
|
||||
gif_webp_frame = ctk.CTkFrame(self.options_frame, fg_color="transparent")
|
||||
gif_webp_frame.pack(fill="x", pady=(4, 0))
|
||||
ctk.CTkLabel(gif_webp_frame, text="Animated:", font=ctk.CTkFont(family="", size=11, weight="bold")).pack(anchor="center")
|
||||
for fmt_key, fmt_label in [("gif", "GIF"), ("webp", "WebP (animated)")]:
|
||||
rb = ctk.CTkRadioButton(
|
||||
gif_webp_frame, text=fmt_label, value=fmt_key,
|
||||
variable=self._format_var,
|
||||
command=self._on_format_change,
|
||||
)
|
||||
rb.pack(side="left", padx=(0, 8))
|
||||
|
||||
# Quality controls
|
||||
self._add_quality_slider(self.options_frame, default=23,
|
||||
label_text="Video CRF (0-51): ",
|
||||
crf_max=51)
|
||||
self._add_audio_quality_slider(self.options_frame, default=85)
|
||||
self._add_loop_checkbox(self.options_frame)
|
||||
self._maybe_update_transform()
|
||||
self._maybe_update_quality_controls()
|
||||
|
||||
def _build_video_audio_panel(self):
|
||||
audio_streams = [s for s in self.streams if s.codec_type == "audio"]
|
||||
if len(audio_streams) == 0:
|
||||
ctk.CTkLabel(self.options_frame, text="No audio tracks found in this video.",
|
||||
text_color="#cc6666").pack(anchor="center", pady=(4, 0))
|
||||
ctk.CTkLabel(self.options_frame, text="Cannot convert video without audio.",
|
||||
text_color="gray").pack(anchor="center")
|
||||
self.transform_btn.configure(state="disabled")
|
||||
return
|
||||
if len(audio_streams) == 1:
|
||||
self.config.audio_streams = [audio_streams[0].index]
|
||||
else:
|
||||
self._add_stream_selection(
|
||||
self.options_frame, audio_streams, "audio",
|
||||
)
|
||||
|
||||
self._add_radio_group(self.options_frame, AUDIO_FORMATS, "mp3")
|
||||
self._add_quality_slider(self.options_frame, default=85,
|
||||
label_text="Quality (0-100): ")
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_audio_image_panel(self):
|
||||
self._add_radio_group(self.options_frame, {
|
||||
"png": "PNG", "jpg": "JPG", "webp": "WebP", "avif": "AVIF",
|
||||
}, "png")
|
||||
self._add_quality_slider(self.options_frame, default=85,
|
||||
label_text="Quality (0-100): ")
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_audio_video_panel(self):
|
||||
self._add_radio_group(self.options_frame, {
|
||||
"mp4": "MP4", "mkv": "MKV", "webm": "WebM",
|
||||
"avi": "AVI", "mov": "MOV",
|
||||
}, "mp4")
|
||||
self._add_quality_slider(self.options_frame, default=23,
|
||||
label_text="Video CRF (0-51): ",
|
||||
crf_max=51)
|
||||
self._add_audio_quality_slider(self.options_frame, default=85)
|
||||
self._maybe_update_transform()
|
||||
|
||||
def _build_audio_audio_panel(self):
|
||||
audio_streams = [s for s in self.streams if s.codec_type == "audio"]
|
||||
if len(audio_streams) == 0:
|
||||
ctk.CTkLabel(self.options_frame, text="No audio streams found in file.",
|
||||
text_color="#cc6666").pack(anchor="center", pady=(4, 0))
|
||||
ctk.CTkLabel(self.options_frame, text="Cannot convert without audio.",
|
||||
text_color="gray").pack(anchor="center")
|
||||
self.transform_btn.configure(state="disabled")
|
||||
return
|
||||
if len(audio_streams) == 1:
|
||||
self.config.audio_streams = [audio_streams[0].index]
|
||||
else:
|
||||
self._add_stream_selection(
|
||||
self.options_frame, audio_streams, "audio",
|
||||
)
|
||||
|
||||
self._add_radio_group(self.options_frame, AUDIO_FORMATS, "mp3")
|
||||
self._add_quality_slider(self.options_frame, default=85,
|
||||
label_text="Quality (0-100): ")
|
||||
self._maybe_update_transform()
|
||||
|
||||
# Action handlers
|
||||
|
||||
def _on_transform(self):
|
||||
"""Execute the conversion in a background thread."""
|
||||
# Gather values
|
||||
self.config.target_format = self._format_var.get()
|
||||
quality_val = self._quality_var.get()
|
||||
# Slider already reports the actual quality value directly
|
||||
# (CRF mode: 0..crf_max; Standard mode: 0..100)
|
||||
self.config.quality = quality_val
|
||||
|
||||
self.config.audio_quality = getattr(self, "_audio_quality_var", None) and self._audio_quality_var.get() or 85
|
||||
self.config.loop = getattr(self, "_loop_var", None) and self._loop_var.get() or False
|
||||
|
||||
# Collect selected streams
|
||||
if hasattr(self, "_stream_vars"): # stream selection only used for audio video conversions
|
||||
self.config.audio_streams = [
|
||||
idx for idx, var in self._stream_vars.items()
|
||||
if var.get()
|
||||
]
|
||||
if not self.config.audio_streams:
|
||||
msg = "No audio streams selected. Conversion may fail."
|
||||
print(f"Transmutate — Warning: {msg}")
|
||||
_show_message(self.root, "Transmutate — Warning", msg, kind="warning")
|
||||
return
|
||||
|
||||
# Determine output path
|
||||
out_ext = self._ext_for_format(self.config.target_format)
|
||||
self.config.output_file = os.path.join(
|
||||
self._input_dir, f"{self._input_name}.{out_ext}"
|
||||
)
|
||||
|
||||
# Check overwrite
|
||||
if os.path.exists(self.config.output_file):
|
||||
if not _ask_question(
|
||||
self.root,
|
||||
"File Exists",
|
||||
f"{os.path.basename(self.config.output_file)} already exists.",
|
||||
):
|
||||
# Auto-rename
|
||||
counter = 1
|
||||
while True:
|
||||
renamed = os.path.join(
|
||||
self._input_dir,
|
||||
f"{self._input_name}[{counter}].{out_ext}",
|
||||
)
|
||||
if not os.path.exists(renamed):
|
||||
self.config.output_file = renamed
|
||||
break
|
||||
counter += 1
|
||||
|
||||
# Preflight check
|
||||
ok, err_msg = preflight_check(self.config.source_file)
|
||||
if not ok:
|
||||
print(f"Transmutate — Error: {err_msg}")
|
||||
_show_message(self.root, "Transmutate — Error", err_msg, kind="error")
|
||||
return
|
||||
|
||||
# Build command
|
||||
try:
|
||||
cmd = self._build_command()
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
print(f"Transmutate — Error: {msg}")
|
||||
_show_message(self.root, "Transmutate — Error", msg, kind="error")
|
||||
return
|
||||
|
||||
# Disable UI during conversion
|
||||
self._set_ui_enabled(False)
|
||||
self.transform_btn.configure(state="disabled")
|
||||
|
||||
def run_conversion():
|
||||
try:
|
||||
self.transform_btn.configure(text="Converting…", state="disabled")
|
||||
|
||||
success, msg = run_command(cmd)
|
||||
|
||||
if success:
|
||||
self.root.after(0, lambda: _show_message(
|
||||
self.root, "Transmutation Complete", "Conversion finished.", kind="info"))
|
||||
else:
|
||||
error_msg = f"Conversion failed.\n\n{msg}" if msg else "Unknown error."
|
||||
print(f"Transmutate — Error: {error_msg}")
|
||||
self.root.after(0, lambda m=error_msg: _show_message(
|
||||
self.root, "Transmutate — Error", m, kind="error"))
|
||||
except Exception as exc:
|
||||
exc_val = str(exc)
|
||||
print(f"Transmutate — Error: {exc_val}")
|
||||
self.root.after(0, lambda e=exc_val: _show_message(
|
||||
self.root, "Transmutate — Error", e, kind="error"))
|
||||
finally:
|
||||
self.root.after(0, lambda: self.transform_btn.configure(text="Transmutate", state="normal"))
|
||||
|
||||
threading.Thread(target=run_conversion, daemon=True).start()
|
||||
|
||||
def _set_ui_enabled(self, enabled: bool):
|
||||
"""Enable/disable all interactive widgets."""
|
||||
state = "normal" if enabled else "disabled"
|
||||
for widget in self.options_frame.winfo_children():
|
||||
self._set_child_states(widget, state)
|
||||
self.transform_btn.configure(state=state)
|
||||
if not enabled:
|
||||
self.info_label.configure(text_color="#888")
|
||||
|
||||
@staticmethod
|
||||
def _set_child_states(parent, state):
|
||||
"""Recursively set state on all child widgets."""
|
||||
try:
|
||||
children = parent.winfo_children()
|
||||
except Exception:
|
||||
return
|
||||
for child in children:
|
||||
if isinstance(child, (ctk.CTkCheckBox, ctk.CTkRadioButton, ctk.CTkSlider)):
|
||||
child.configure(state=state)
|
||||
elif isinstance(child, ctk.CTkButton) and child is not None:
|
||||
child.configure(state=state)
|
||||
else:
|
||||
TransmutateApp._set_child_states(child, state)
|
||||
|
||||
# Command builder
|
||||
|
||||
def _build_command(self):
|
||||
"""Build an ffmpeg/magick command list for the current conversion."""
|
||||
src = self.config.source_file
|
||||
dst = self.config.output_file
|
||||
fmt = self.config.target_format
|
||||
quality = self.config.quality
|
||||
aq = self.config.audio_quality
|
||||
audio_streams = self.config.audio_streams
|
||||
sub_streams = self.config.sub_streams
|
||||
is_animated = self.config.is_animated
|
||||
loop = self.config.loop
|
||||
mime = self.config.mime_type
|
||||
|
||||
media = self.config.media_type
|
||||
|
||||
if media == "image":
|
||||
return _build_image_command(
|
||||
src, dst, fmt, quality,
|
||||
is_animated, loop, mime,
|
||||
)
|
||||
|
||||
elif media == "video":
|
||||
if fmt == "gif":
|
||||
# Video → GIF: use palettegen-based command
|
||||
return _build_video_gif(src, dst, quality)
|
||||
elif fmt == "webp":
|
||||
# Video → animated WebP: use libwebp_anim
|
||||
return _build_video_webp(src, dst, quality, loop)
|
||||
elif fmt in AUDIO_FORMATS:
|
||||
# Video → audio (e.g. MP4 → MP3)
|
||||
return _build_audio_command(
|
||||
src, dst, fmt, quality, audio_streams,
|
||||
)
|
||||
return _build_video_command(
|
||||
src, dst, fmt, quality, aq,
|
||||
audio_streams, sub_streams, mime,
|
||||
)
|
||||
|
||||
elif media == "audio":
|
||||
return _build_audio_command(
|
||||
src, dst, fmt, quality, audio_streams,
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown media type: {media}")
|
||||
|
||||
@staticmethod
|
||||
def _ext_for_format(fmt: str) -> str:
|
||||
"""Map a format key to its file extension."""
|
||||
return {
|
||||
"mp3": "mp3", "flac": "flac", "wav": "wav",
|
||||
"ogg": "ogg", "m4a": "m4a", "aac": "aac",
|
||||
"png": "png", "jpg": "jpg", "webp": "webp",
|
||||
"avif": "avif", "gif": "gif",
|
||||
"mp4": "mp4", "mkv": "mkv", "webm": "webm",
|
||||
"avi": "avi", "mov": "mov",
|
||||
}.get(fmt, fmt)
|
||||
|
||||
|
||||
def open_file(filepath: str):
|
||||
"""Entry point: launch the Transmutate GUI for *filepath*."""
|
||||
ctk.set_appearance_mode("System")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
root = ctk.CTk()
|
||||
TransmutateApp(root, filepath)
|
||||
root.mainloop()
|
||||
Reference in New Issue
Block a user