389 lines
16 KiB
Python
389 lines
16 KiB
Python
#!/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()
|