"""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()