python-tinypng/tinypng_gui.py

389 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""TinyPNG 批量压缩图形界面。"""
import os
import queue
import sys
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tinypng_balancer import BatchArgs, CONVERT_SOURCE, CONVERT_TYPES, run_batch, write_json
def app_base_dir():
if getattr(sys, "frozen", False):
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
BASE_DIR = app_base_dir()
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)
ttk.Label(
top,
text="示例: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080留空则不使用代理",
foreground="#666666",
).grid(row=3, column=1, columnspan=2, sticky="w", padx=8, pady=(0, 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_vars = {
CONVERT_SOURCE: tk.BooleanVar(value=True),
"png": tk.BooleanVar(value=False),
"jpeg": tk.BooleanVar(value=False),
"webp": tk.BooleanVar(value=True),
"avif": tk.BooleanVar(value=False),
}
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_frame = ttk.Frame(options)
convert_frame.grid(row=1, column=1, columnspan=3, sticky="w", pady=(10, 0))
ttk.Checkbutton(convert_frame, text="源格式", variable=self.convert_vars[CONVERT_SOURCE]).grid(row=0, column=0, sticky="w")
ttk.Checkbutton(convert_frame, text="png", variable=self.convert_vars["png"]).grid(row=0, column=1, sticky="w", padx=(8, 0))
ttk.Checkbutton(convert_frame, text="jpeg", variable=self.convert_vars["jpeg"]).grid(row=0, column=2, sticky="w", padx=(8, 0))
ttk.Checkbutton(convert_frame, text="webp", variable=self.convert_vars["webp"]).grid(row=0, column=3, sticky="w", padx=(8, 0))
ttk.Checkbutton(convert_frame, text="avif", variable=self.convert_vars["avif"]).grid(row=0, column=4, sticky="w", padx=(8, 0))
ttk.Button(convert_frame, text="全选", command=self._toggle_all_formats, width=6).grid(row=0, column=5, sticky="w", padx=(10, 0))
ttk.Label(options, text="缩放方式").grid(row=1, column=4, 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=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.width_var, width=8).grid(row=1, column=7, sticky="w", pady=(10, 0))
ttk.Label(options, text="").grid(row=2, column=6, sticky="e", pady=(10, 0))
ttk.Entry(options, textvariable=self.height_var, width=8).grid(row=2, 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 _toggle_all_formats(self):
should_select = not all(var.get() for var in self.convert_vars.values())
for var in self.convert_vars.values():
var.set(should_select)
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
convert = [name for name, var in self.convert_vars.items() if var.get()]
if not convert:
messagebox.showwarning("参数错误", "请至少选择一种转换格式。")
return
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()