#!/usr/bin/env python # -*- coding: utf-8 -*- """TinyPNG 批量压缩图形界面。""" import os import queue import threading import tkinter as tk from tkinter import filedialog, messagebox, ttk from tinypng_balancer import BatchArgs, CONVERT_ALL, CONVERT_TYPES, run_batch, write_json BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_INPUT_DIR = os.path.join(BASE_DIR, "source") class KeyConfigDialog(tk.Toplevel): def __init__(self, app): super().__init__(app) self.app = app self.title("填写 API Key") self.geometry("640x460") self.minsize(560, 380) self.transient(app) self.path_var = tk.StringVar(value=app.config_var.get() or os.path.join(BASE_DIR, "tinify_keys.json")) self.limit_var = tk.StringVar(value="500") self.proxy_var = tk.StringVar(value="") self._build_ui() self.grab_set() self.text.focus_set() def _build_ui(self): self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) top = ttk.Frame(self, padding=12) top.grid(row=0, column=0, sticky="ew") top.columnconfigure(1, weight=1) ttk.Label(top, text="保存到").grid(row=0, column=0, sticky="w", pady=4) ttk.Entry(top, textvariable=self.path_var).grid(row=0, column=1, sticky="ew", padx=8, pady=4) ttk.Button(top, text="选择", command=self._choose_path).grid(row=0, column=2, pady=4) ttk.Label(top, text="每个 Key 月额度").grid(row=1, column=0, sticky="w", pady=4) ttk.Entry(top, textvariable=self.limit_var, width=10).grid(row=1, column=1, sticky="w", padx=8, pady=4) ttk.Label(top, text="代理").grid(row=2, column=0, sticky="w", pady=4) ttk.Entry(top, textvariable=self.proxy_var).grid(row=2, column=1, columnspan=2, sticky="ew", padx=8, pady=4) body = ttk.Frame(self, padding=(12, 0, 12, 12)) body.grid(row=1, column=0, sticky="nsew") body.rowconfigure(1, weight=1) body.columnconfigure(0, weight=1) ttk.Label(body, text="API Key(一行一个,也可以用逗号或空格分隔)").grid(row=0, column=0, sticky="w") text_frame = ttk.Frame(body) text_frame.grid(row=1, column=0, sticky="nsew", pady=(6, 0)) text_frame.rowconfigure(0, weight=1) text_frame.columnconfigure(0, weight=1) self.text = tk.Text(text_frame, height=12, wrap="word") self.text.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(text_frame, orient="vertical", command=self.text.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.text.configure(yscrollcommand=scrollbar.set) actions = ttk.Frame(body) actions.grid(row=2, column=0, sticky="ew", pady=(10, 0)) actions.columnconfigure(0, weight=1) ttk.Button(actions, text="取消", command=self.destroy).grid(row=0, column=1, padx=(8, 0)) ttk.Button(actions, text="保存配置", command=self._save).grid(row=0, column=2, padx=(8, 0)) def _choose_path(self): path = filedialog.asksaveasfilename( title="保存 API Key 配置", initialdir=BASE_DIR, initialfile="tinify_keys.json", defaultextension=".json", filetypes=[("JSON 文件", "*.json"), ("所有文件", "*.*")], ) if path: self.path_var.set(path) def _parse_keys(self): raw = self.text.get("1.0", "end") pieces = [] for chunk in raw.replace(",", "\n").replace(";", "\n").splitlines(): pieces.extend(chunk.split()) keys = [] seen = set() for item in pieces: key = item.strip() if not key or key in seen: continue seen.add(key) keys.append(key) return keys def _save(self): path = self.path_var.get().strip() if not path: messagebox.showwarning("缺少保存路径", "请选择配置文件保存位置。", parent=self) return try: monthly_limit = int(self.limit_var.get().strip()) except ValueError: messagebox.showwarning("额度错误", "每个 Key 月额度必须是数字。", parent=self) return if monthly_limit <= 0: messagebox.showwarning("额度错误", "每个 Key 月额度必须大于 0。", parent=self) return keys = self._parse_keys() if not keys: messagebox.showwarning("缺少 API Key", "请至少输入一个 API Key。", parent=self) return config = { "api_keys": [ {"name": "账号-{0}".format(index + 1), "key": key, "monthly_limit": monthly_limit} for index, key in enumerate(keys) ], "proxy": self.proxy_var.get().strip() or None, } write_json(path, config) self.app.config_var.set(path) messagebox.showinfo("已保存", "已保存 {0} 个 API Key。".format(len(keys)), parent=self) self.destroy() class TinypngApp(tk.Tk): def __init__(self): super().__init__() self.title("TinyPNG 批量压缩") self.geometry("860x600") self.minsize(760, 520) self.messages = queue.Queue() self.worker = None os.makedirs(DEFAULT_INPUT_DIR, exist_ok=True) self.input_var = tk.StringVar(value=DEFAULT_INPUT_DIR) self.output_var = tk.StringVar(value=os.path.join(BASE_DIR, "optimized")) self.config_var = tk.StringVar(value=os.path.join(BASE_DIR, "tinify_keys.json")) self.state_var = tk.StringVar(value=os.path.join(BASE_DIR, "tinify_usage.json")) self.convert_var = tk.StringVar(value="保持原格式") self.resize_var = tk.StringVar(value="不缩放") self.width_var = tk.StringVar(value="") self.height_var = tk.StringVar(value="") self.recursive_var = tk.BooleanVar(value=True) self.overwrite_var = tk.BooleanVar(value=False) self.dry_run_var = tk.BooleanVar(value=False) self.sync_usage_var = tk.BooleanVar(value=True) self._build_ui() self.after(100, self._drain_messages) def _build_ui(self): self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) main = ttk.Frame(self, padding=14) main.grid(row=0, column=0, sticky="nsew") main.columnconfigure(1, weight=1) self._path_row(main, 0, "输入目录", self.input_var, self._choose_input) self._path_row(main, 1, "输出目录", self.output_var, self._choose_output) self._path_row(main, 2, "配置文件", self.config_var, self._choose_config) self._path_row(main, 3, "用量记录", self.state_var, self._choose_state) options = ttk.Frame(main) options.grid(row=4, column=0, columnspan=3, sticky="ew", pady=(12, 0)) for index in range(8): options.columnconfigure(index, weight=1) ttk.Checkbutton(options, text="递归扫描", variable=self.recursive_var).grid(row=0, column=0, sticky="w") ttk.Checkbutton(options, text="覆盖输出", variable=self.overwrite_var).grid(row=0, column=1, sticky="w") ttk.Checkbutton(options, text="试运行", variable=self.dry_run_var).grid(row=0, column=2, sticky="w") ttk.Checkbutton(options, text="同步官方额度", variable=self.sync_usage_var).grid(row=0, column=3, sticky="w") ttk.Label(options, text="转换格式").grid(row=1, column=0, sticky="w", pady=(10, 0)) convert_box = ttk.Combobox( options, textvariable=self.convert_var, values=["保持原格式", "全部格式"] + sorted(CONVERT_TYPES.keys()), state="readonly", width=12, ) convert_box.grid(row=1, column=1, sticky="w", pady=(10, 0)) ttk.Label(options, text="缩放方式").grid(row=1, column=2, sticky="w", pady=(10, 0)) resize_box = ttk.Combobox( options, textvariable=self.resize_var, values=["不缩放", "scale", "fit", "cover", "thumb"], state="readonly", width=12, ) resize_box.grid(row=1, column=3, sticky="w", pady=(10, 0)) ttk.Label(options, text="宽").grid(row=1, column=4, sticky="e", pady=(10, 0)) ttk.Entry(options, textvariable=self.width_var, width=8).grid(row=1, column=5, sticky="w", pady=(10, 0)) ttk.Label(options, text="高").grid(row=1, column=6, sticky="e", pady=(10, 0)) ttk.Entry(options, textvariable=self.height_var, width=8).grid(row=1, column=7, sticky="w", pady=(10, 0)) actions = ttk.Frame(main) actions.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(14, 0)) actions.columnconfigure(0, weight=1) self.start_btn = ttk.Button(actions, text="开始处理", command=self._start) self.start_btn.grid(row=0, column=1, padx=(8, 0)) ttk.Button(actions, text="填写 API Key", command=self._open_key_editor).grid(row=0, column=2, padx=(8, 0)) log_frame = ttk.Frame(self, padding=(14, 0, 14, 14)) log_frame.grid(row=1, column=0, sticky="nsew") log_frame.rowconfigure(0, weight=1) log_frame.columnconfigure(0, weight=1) self.log = tk.Text(log_frame, height=16, wrap="word") self.log.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.log.configure(yscrollcommand=scrollbar.set) def _path_row(self, parent, row, label, variable, command): ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4) ttk.Entry(parent, textvariable=variable).grid(row=row, column=1, sticky="ew", padx=8, pady=4) ttk.Button(parent, text="选择", command=command).grid(row=row, column=2, sticky="e", pady=4) def _choose_input(self): path = filedialog.askdirectory(title="选择输入目录") if path: self.input_var.set(path) def _choose_output(self): path = filedialog.askdirectory(title="选择输出目录") if path: self.output_var.set(path) def _choose_config(self): path = filedialog.askopenfilename( title="选择 API Key 配置文件", filetypes=[("JSON 文件", "*.json"), ("所有文件", "*.*")], ) if path: self.config_var.set(path) def _choose_state(self): path = filedialog.asksaveasfilename( title="选择用量记录文件", defaultextension=".json", filetypes=[("JSON 文件", "*.json"), ("所有文件", "*.*")], ) if path: self.state_var.set(path) def _open_key_editor(self): KeyConfigDialog(self) def _parse_size(self, value, name): value = value.strip() if not value: return None try: parsed = int(value) except ValueError: raise ValueError("{0}必须是数字。".format(name)) if parsed <= 0: raise ValueError("{0}必须大于 0。".format(name)) return parsed def _start(self): if self.worker and self.worker.is_alive(): return input_dir = self.input_var.get().strip() output_dir = self.output_var.get().strip() config_path = self.config_var.get().strip() state_path = self.state_var.get().strip() if not input_dir: messagebox.showwarning("缺少输入目录", "请先选择输入目录。") return if not output_dir: messagebox.showwarning("缺少输出目录", "请先选择输出目录。") return if not config_path: messagebox.showwarning("缺少配置文件", "请先选择 API Key 配置文件。") return try: width = self._parse_size(self.width_var.get(), "宽度") height = self._parse_size(self.height_var.get(), "高度") except ValueError as exc: messagebox.showwarning("参数错误", str(exc)) return resize_method = None if self.resize_var.get() == "不缩放" else self.resize_var.get() if resize_method and not (width or height): messagebox.showwarning("参数错误", "选择缩放方式后,请填写宽度或高度。") return if self.convert_var.get() == "保持原格式": convert = None elif self.convert_var.get() == "全部格式": convert = CONVERT_ALL else: convert = self.convert_var.get() args = BatchArgs( resize_method=resize_method, width=width, height=height, convert=convert, recursive=self.recursive_var.get(), overwrite=self.overwrite_var.get(), dry_run=self.dry_run_var.get(), sync_usage=self.sync_usage_var.get(), ) self.log.delete("1.0", "end") self._append_log("开始处理...\n") self.start_btn.configure(state="disabled") self.worker = threading.Thread( target=self._run_worker, args=(input_dir, output_dir, config_path, state_path, args), daemon=True, ) self.worker.start() def _run_worker(self, input_dir, output_dir, config_path, state_path, args): try: run_batch([input_dir], output_dir, config_path, state_path, args, self.messages.put) self.messages.put("__DONE__") except BaseException as exc: self.messages.put("__ERROR__{0}".format(exc)) def _append_log(self, message): self.log.insert("end", message) self.log.see("end") def _drain_messages(self): try: while True: message = self.messages.get_nowait() if message == "__DONE__": self._append_log("全部完成。\n") self.start_btn.configure(state="normal") messagebox.showinfo("完成", "图片处理已完成。") elif message.startswith("__ERROR__"): self._append_log("错误:{0}\n".format(message[len("__ERROR__") :])) self.start_btn.configure(state="normal") messagebox.showerror("运行失败", message[len("__ERROR__") :]) else: self._append_log(str(message) + "\n") except queue.Empty: pass self.after(100, self._drain_messages) if __name__ == "__main__": TinypngApp().mainloop()