#!/usr/bin/env python3 """ Dependencies: pip install pillow """ import os import threading from pathlib import Path from tkinter import ( Tk, Frame, Label, Button, Scale, IntVar, BooleanVar, StringVar, HORIZONTAL, filedialog, messagebox, ttk, Entry, Checkbutton ) from PIL import Image, ImageOps IMAGE_FILETYPES = [("Image files", "*.jpg *.JPG *.jpeg *.png *.bmp *.tiff *.webp *.gif *.tif *.tiff"), ("All files", "*.*")] class WebPBatchConverter: def __init__(self, root: Tk): self.root = root self.root.title("WebP Batch Converter") self.root.geometry("620x500") self.root.resizable(False, False) # State self.files = [] self.output_dir = StringVar(value="") self.quality = IntVar(value=85) self.delete_original = BooleanVar(value=False) self.export_same_dir = BooleanVar(value=True) self.export_custom_dir = BooleanVar(value=False) self.resize_enabled = BooleanVar(value=False) self.width_var = StringVar(value="1024") self.height_var = StringVar(value="768") # Widget references we need to change state for self.files_label = None self.quality_slider = None self.out_entry = None self.width_entry = None self.height_entry = None self.files_button = None self.browse_output_button = None self.start_button = None self.quit_button = None # Checkbutton references (so we can enable/disable them) self.export_same_chk = None self.delete_chk = None self.custom_chk = None # UI self._build_ui() # Progress state self.total_count = 0 def _build_ui(self): padx = 12 Label(self.root, text="Select image files to convert to WebP", font=("TkDefaultFont", 13)).pack(pady=(10, 6)) # File selection frame frm_files = Frame(self.root) frm_files.pack(fill="x", padx=padx) self.files_button = Button(frm_files, text="Choose Images...", command=self.choose_images, width=16) self.files_button.grid(row=0, column=0, sticky="w") self.files_label = Label(frm_files, text="No files selected", anchor="w") self.files_label.grid(row=0, column=1, sticky="w", padx=(10, 0)) # Options frame frm_opts = Frame(self.root) frm_opts.pack(fill="x", padx=padx, pady=(10, 0)) Label(frm_opts, text="WebP quality:").grid(row=0, column=0, sticky="w") self.quality_slider = Scale(frm_opts, from_=10, to=100, orient=HORIZONTAL, variable=self.quality, length=300) self.quality_slider.grid(row=0, column=1, columnspan=3, sticky="w", padx=(8,0)) # Export options Label(frm_opts, text="Export options:").grid(row=1, column=0, sticky="nw", pady=(8,0)) cb_frame = Frame(frm_opts) cb_frame.grid(row=1, column=1, columnspan=3, sticky="w", padx=(8,0), pady=(8,0)) # store checkbutton refs so we can enable/disable them when needed self.export_same_chk = Checkbutton(cb_frame, text="Save next to original (keep originals)", variable=self.export_same_dir, command=self._sync_export_options) self.export_same_chk.grid(row=0, column=0, sticky="w") self.delete_chk = Checkbutton(cb_frame, text="Delete original after conversion", variable=self.delete_original, command=self._sync_export_options) self.delete_chk.grid(row=1, column=0, sticky="w") self.custom_chk = Checkbutton(cb_frame, text="Export to custom folder", variable=self.export_custom_dir, command=self._sync_export_options) self.custom_chk.grid(row=2, column=0, sticky="w") # Output folder chooser out_frame = Frame(self.root) out_frame.pack(fill="x", padx=padx, pady=(6, 0)) Label(out_frame, text="Custom folder:").grid(row=0, column=0, sticky="w") self.out_entry = Entry(out_frame, textvariable=self.output_dir, width=52, state="disabled") self.out_entry.grid(row=0, column=1, padx=(8, 4)) self.browse_output_button = Button(out_frame, text="Browse...", command=self.choose_output_folder, state="disabled") self.browse_output_button.grid(row=0, column=2) # Resize options (note: preserve aspect removed; resizing will be exact size) resize_frame = Frame(self.root) resize_frame.pack(fill="x", padx=padx, pady=(10, 0)) Checkbutton(resize_frame, text="Resize images", variable=self.resize_enabled, command=self._toggle_resize_inputs).grid(row=0, column=0, sticky="w") Label(resize_frame, text="Width:").grid(row=1, column=0, sticky="e", pady=(6,0)) self.width_entry = Entry(resize_frame, textvariable=self.width_var, width=10, state="disabled") self.width_entry.grid(row=1, column=1, sticky="w", pady=(6,0), padx=(6, 20)) Label(resize_frame, text="Height:").grid(row=1, column=2, sticky="e", pady=(6,0)) self.height_entry = Entry(resize_frame, textvariable=self.height_var, width=10, state="disabled") self.height_entry.grid(row=1, column=3, sticky="w", pady=(6,0), padx=(6, 20)) # Progress + Buttons bottom_frame = Frame(self.root) bottom_frame.pack(fill="x", padx=padx, pady=(12, 8)) self.progress = ttk.Progressbar(bottom_frame, orient='horizontal', mode='determinate', length=520) self.progress.grid(row=0, column=0, columnspan=4, pady=(0,8)) self.status_label = Label(bottom_frame, text="Ready") self.status_label.grid(row=1, column=0, sticky="w") self.start_button = Button(bottom_frame, text="Start Conversion", command=self.start_conversion, width=16) self.start_button.grid(row=1, column=2, sticky="e", padx=(8,0)) self.quit_button = Button(bottom_frame, text="Quit", command=self.root.quit, width=10) self.quit_button.grid(row=1, column=3, sticky="e", padx=(8,0)) # initialize states self._toggle_resize_inputs() self._sync_export_options() def choose_images(self): files = filedialog.askopenfilenames(title="Select images", filetypes=IMAGE_FILETYPES) if files: self.files = list(files) display = f"{len(self.files)} file(s) selected" if len(self.files) <= 6: display = ", ".join([Path(p).name for p in self.files]) self.files_label.config(text=display) self.status_label.config(text="Files selected") else: self.files = [] self.files_label.config(text="No files selected") self.status_label.config(text="Ready") def choose_output_folder(self): d = filedialog.askdirectory(title="Choose output folder") if d: self.output_dir.set(d) self.export_custom_dir.set(True) self.export_same_dir.set(False) self._sync_export_options() def _toggle_resize_inputs(self): state = "normal" if self.resize_enabled.get() else "disabled" self.width_entry.config(state=state) self.height_entry.config(state=state) return def _sync_export_options(self): """ Rules enforced: - If custom folder selected -> uncheck & disable 'save next to original'. - If delete original selected -> uncheck & disable 'save next to original' (because they contradict). - If neither delete nor custom selected -> enable 'save next to original'. """ # If custom folder selected, turn off save-next-to-original if self.export_custom_dir.get(): self.export_same_dir.set(False) # If delete-original selected, turn off save-next-to-original if self.delete_original.get(): self.export_same_dir.set(False) # If neither custom nor delete are selected and export_same_dir was False (user cleared both), # default to export_same_dir True to keep a sensible default if not self.export_custom_dir.get() and not self.delete_original.get() and not self.export_same_dir.get(): self.export_same_dir.set(True) # Enable/disable the 'save next to original' checkbutton depending on conflicts if self.export_custom_dir.get() or self.delete_original.get(): try: self.export_same_chk.configure(state="disabled") except Exception: pass else: try: self.export_same_chk.configure(state="normal") except Exception: pass # Enable/disable custom folder controls if self.export_custom_dir.get(): self.out_entry.config(state="normal") self.browse_output_button.config(state="normal") else: self.out_entry.config(state="disabled") self.browse_output_button.config(state="disabled") # Update status text so user sees what's happening if self.delete_original.get() and self.export_custom_dir.get(): self.status_label.config(text="Will save WebP to custom folder, then delete original files.") elif self.delete_original.get(): self.status_label.config(text="Will save WebP (next to originals if selected) then delete original files.") elif self.export_custom_dir.get(): self.status_label.config(text="Will save WebP to custom folder.") else: self.status_label.config(text="Ready") def _unique_target(self, p: Path) -> Path: if not p.exists(): return p base = p.stem parent = p.parent i = 1 while True: candidate = parent / f"{base}_converted{i}{p.suffix}" if not candidate.exists(): return candidate i += 1 def start_conversion(self): if not self.files: messagebox.showwarning("No files", "Please select one or more image files to convert.") return q = self.quality.get() if not (0 <= q <= 100): messagebox.showerror("Quality out of range", "Quality must be between 0 and 100.") return if self.resize_enabled.get(): try: w = int(self.width_var.get()) h = int(self.height_var.get()) if w <= 0 or h <= 0: raise ValueError except Exception: messagebox.showerror("Invalid size", "Please enter positive integers for width and height.") return if self.export_custom_dir.get(): out = self.output_dir.get().strip() if not out: messagebox.showerror("No output folder", "Please choose a custom output folder or uncheck 'Export to custom folder'.") return if not os.path.isdir(out): try: os.makedirs(out, exist_ok=True) except Exception as e: messagebox.showerror("Invalid output folder", f"Cannot create or access the output folder:\n{e}") return # disable main controls while running (leave progress & status active) self._set_ui_state(disabled=True) self.total_count = len(self.files) self.progress["maximum"] = self.total_count self.progress["value"] = 0 self.status_label.config(text="Starting conversion...") thread = threading.Thread(target=self._worker_convert, daemon=True) thread.start() def _set_ui_state(self, disabled: bool): state = "disabled" if disabled else "normal" for child in self.root.winfo_children(): for sub in child.winfo_children(): try: # keep progress & status usable if sub not in (self.progress, self.status_label): sub.configure(state=state) except Exception: pass try: self.progress.configure(state="normal") except Exception: pass try: self.status_label.configure(state="normal") except Exception: pass def _worker_convert(self): success = 0 errors = 0 for idx, filepath in enumerate(self.files, start=1): try: src_path = Path(filepath) img = Image.open(src_path) # Apply EXIF orientation so the image pixels are oriented correctly before any processing. # This ensures the saved WebP will have the correct orientation regardless of EXIF. try: img = ImageOps.exif_transpose(img) except Exception: # If exif_transpose isn't available or fails, continue without raising; # image will be processed as-is (may be mis-oriented). pass # Resize if requested -> exact resize (preserve-aspect option removed) if self.resize_enabled.get(): target_w = int(self.width_var.get()) target_h = int(self.height_var.get()) img = img.resize((target_w, target_h), Image.LANCZOS) # Prepare saving path if self.export_custom_dir.get(): out_dir = Path(self.output_dir.get()) else: out_dir = src_path.parent name_root = src_path.stem out_path = out_dir / f"{name_root}.webp" # Avoid clobbering if out_path.exists(): out_path = self._unique_target(out_path) # Preserve alpha if present try: mode = img.mode if "A" in mode or mode == "RGBA": save_img = img.convert("RGBA") else: save_img = img.convert("RGB") except Exception: save_img = img.convert("RGB") # Save as WebP with selected quality. # We intentionally do not copy EXIF orientation tag into the WebP: we've already applied it. save_img.save(out_path, format="WEBP", quality=int(self.quality.get())) # Delete original file if requested if self.delete_original.get(): try: src_path.unlink() except Exception as e: print(f"Warning: could not delete original {src_path}: {e}") success += 1 except Exception as e: errors += 1 print(f"Failed processing {filepath}: {e}") # Update progress on main thread self.root.after(0, self._update_progress, idx, self.total_count, f"Processing {idx}/{self.total_count}") summary = f"Finished. {success} succeeded" if errors: summary += f", {errors} failed." else: summary += "." self.root.after(0, self._on_complete, summary) def _update_progress(self, done_count, total, status_text): self.progress["value"] = done_count self.status_label.config(text=status_text) self.progress.update_idletasks() def _on_complete(self, summary_text): self._set_ui_state(disabled=False) self.status_label.config(text=summary_text) messagebox.showinfo("Conversion complete", summary_text) def main(): try: from PIL import Image # quick check except ImportError: messagebox.showerror("Missing Dependency", "Please install Pillow:\n\npip install pillow") return root = Tk() app = WebPBatchConverter(root) root.mainloop() if __name__ == "__main__": main()