From 8a18f1df9b2b94f6197e84e6d59248029f79689c Mon Sep 17 00:00:00 2001 From: MAOMOMO Date: Sat, 23 May 2026 15:58:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + README.md | 193 +++++++++++++++++++ pyproject.toml | 8 + run_gui.bat | 3 + tinify_keys.sample.json | 15 ++ tinypng_balancer.py | 414 ++++++++++++++++++++++++++++++++++++++++ tinypng_gui.py | 366 +++++++++++++++++++++++++++++++++++ 7 files changed, 1005 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 run_gui.bat create mode 100644 tinify_keys.sample.json create mode 100644 tinypng_balancer.py create mode 100644 tinypng_gui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63d0c48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +tinify_keys.json +tinify_usage.json +optimized +source +uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..395479f --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# TinyPNG 批量压缩工具 + +这是一个基于 Tinify/TinyPNG Python SDK 的批量图片压缩和格式转换工具。支持 GUI 界面、多个 API Key 负载均衡、官方额度同步、批量转换格式,以及递归处理目录。 + +## 功能 + +- 图形界面选择输入目录和输出目录。 +- 支持多个 Tinify API Key,每个 Key 默认每月 500 张额度。 +- 启动处理前可同步官方本月已用额度。 +- 支持 PNG、JPG/JPEG、WebP、AVIF 图片。 +- 支持转换为 png、jpeg、webp、avif,也支持“全部格式”一次输出四种格式。 +- 支持递归扫描目录。 +- 支持覆盖已有输出文件或跳过已有文件。 +- 支持试运行,只预览计划,不调用 Tinify API。 + +## 目录说明 + +```text +C:\dev\py\tinypng +├─ source\ 默认输入目录,把待处理图片放这里 +├─ optimized\ 默认输出目录,运行后自动生成 +├─ tinypng_gui.py GUI 程序 +├─ tinypng_balancer.py 命令行和核心处理逻辑 +├─ run_gui.bat 双击启动 GUI +├─ tinify_keys.json 实际 API Key 配置文件 +├─ tinify_usage.json 本月额度缓存文件,运行后自动生成 +├─ pyproject.toml uv 项目依赖配置 +└─ uv.lock uv 锁定文件 +``` + +## 启动 GUI + +推荐直接双击: + +```text +run_gui.bat +``` + +也可以在命令行运行: + +```powershell +cd C:\dev\py\tinypng +uv run tinypng_gui.py +``` + +程序通过 `uv run` 启动,会自动使用 `pyproject.toml` 中声明的 `tinify` SDK 依赖。 + +## 配置 API Key + +打开 GUI 后点击 `填写 API Key`。 + +支持以下输入方式: + +```text +KEY_1 +KEY_2 +KEY_3 +``` + +也支持用逗号、分号或空格分隔。保存后会生成 `tinify_keys.json`,主界面的“配置文件”会自动指向该文件。 + +配置文件格式示例: + +```json +{ + "api_keys": [ + { + "key": "YOUR_TINIFY_API_KEY_1", + "monthly_limit": 500, + "name": "账号-1" + }, + { + "key": "YOUR_TINIFY_API_KEY_2", + "monthly_limit": 500, + "name": "账号-2" + } + ], + "proxy": null +} +``` + +不要把真实 API Key 写入 `tinify_keys.sample.json`,它只作为模板示例。 + +## GUI 使用流程 + +1. 把图片放入当前目录下的 `source` 文件夹。 +2. 双击 `run_gui.bat` 打开界面。 +3. 点击 `填写 API Key`,粘贴一个或多个 API Key 并保存。 +4. 确认输入目录为 `source`。 +5. 选择输出目录,默认是 `optimized`。 +6. 按需选择转换格式、递归扫描、覆盖输出、同步官方额度等选项。 +7. 点击 `开始处理`。 + +## 转换格式 + +`转换格式` 默认是 `保持原格式`。 + +可选值: + +- `保持原格式`:只压缩,不改变格式。 +- `png`:输出 PNG。 +- `jpeg` / `jpg`:输出 JPEG。 +- `webp`:输出 WebP。 +- `avif`:输出 AVIF。 +- `全部格式`:每张图片分别输出 `.png`、`.jpg`、`.webp`、`.avif`。 + +注意:转换、缩放等 Tinify API 操作可能会消耗压缩额度。“全部格式”会为每张输入图片生成多个输出,因此额度消耗也会更多。 + +## 额度同步 + +GUI 默认勾选 `同步官方额度`。 + +处理开始前,程序会对每个 API Key 调用官方 SDK 的 `tinify.validate()`,然后读取 `tinify.compression_count`,同步该 Key 本月已经使用的数量。 + +同步后的额度会写入: + +```text +tinify_usage.json +``` + +后续每次处理成功后,程序也会读取 SDK 更新后的 `tinify.compression_count` 并回写本地用量缓存。 + +## 命令行用法 + +压缩目录: + +```powershell +uv run tinypng_balancer.py source -o optimized +``` + +递归压缩: + +```powershell +uv run tinypng_balancer.py source -o optimized --recursive +``` + +转换为 WebP: + +```powershell +uv run tinypng_balancer.py source -o optimized --convert webp +``` + +输出全部格式: + +```powershell +uv run tinypng_balancer.py source -o optimized --convert all +``` + +试运行: + +```powershell +uv run tinypng_balancer.py source -o optimized --dry-run +``` + +跳过启动前官方额度同步: + +```powershell +uv run tinypng_balancer.py source -o optimized --no-sync-usage +``` + +## 常见问题 + +### 提示 No module named 'tinify' + +请使用 `uv run` 启动: + +```powershell +uv run tinypng_gui.py +``` + +或者双击 `run_gui.bat`。 + +### 没有找到图片 + +确认图片放在输入目录中。默认输入目录是: + +```text +C:\dev\py\tinypng\source +``` + +支持的扩展名包括: + +```text +.png .jpg .jpeg .webp .avif +``` + +### API Key 不可用 + +检查 `tinify_keys.json` 中是否写入了真实 Key。模板中的 `YOUR_TINIFY_API_KEY_1` 不能直接使用。 + +### 全部格式输出时额度消耗变多 + +这是正常的。因为每张图片会分别转换为 PNG、JPEG、WebP、AVIF 四种格式,每个输出都可能消耗 Tinify 额度。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7bcd59 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "tinypng-balancer" +version = "0.1.0" +description = "TinyPNG GUI batch compressor with multiple Tinify API keys." +requires-python = ">=3.9" +dependencies = [ + "tinify>=1.6.0", +] diff --git a/run_gui.bat b/run_gui.bat new file mode 100644 index 0000000..d3f422e --- /dev/null +++ b/run_gui.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +uv run tinypng_gui.py \ No newline at end of file diff --git a/tinify_keys.sample.json b/tinify_keys.sample.json new file mode 100644 index 0000000..239541f --- /dev/null +++ b/tinify_keys.sample.json @@ -0,0 +1,15 @@ +{ + "api_keys": [ + { + "key": "YOUR_TINIFY_API_KEY_1", + "monthly_limit": 500, + "name": "账号-1" + }, + { + "key": "YOUR_TINIFY_API_KEY_2", + "monthly_limit": 500, + "name": "账号-2" + } + ], + "proxy": null +} diff --git a/tinypng_balancer.py b/tinypng_balancer.py new file mode 100644 index 0000000..cb18912 --- /dev/null +++ b/tinypng_balancer.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""TinyPNG/Tinify 多 API Key 负载均衡压缩工具。 + +使用示例: + python tinypng_balancer.py --init-config tinify_keys.json + python tinypng_balancer.py input.png -o output.png --config tinify_keys.json + python tinypng_balancer.py images -o optimized --recursive --convert webp +""" +from __future__ import absolute_import, print_function + +import argparse +import datetime as _dt +import hashlib +import json +import os +import sys +import tempfile + +DEFAULT_LIMIT = 500 +SUPPORTED_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".avif") +CONVERT_TYPES = { + "png": ("image/png", ".png"), + "jpeg": ("image/jpeg", ".jpg"), + "jpg": ("image/jpeg", ".jpg"), + "webp": ("image/webp", ".webp"), + "avif": ("image/avif", ".avif"), +} +CONVERT_ALL = "all" +CONVERT_ALL_TARGETS = ("png", "jpeg", "webp", "avif") + + +class BatchArgs(object): + """给命令行和 GUI 共用的简易参数对象。""" + + def __init__( + self, + resize_method=None, + width=None, + height=None, + convert=None, + recursive=False, + overwrite=False, + dry_run=False, + sync_usage=True, + ): + self.resize_method = resize_method + self.width = width + self.height = height + self.convert = convert + self.recursive = recursive + self.overwrite = overwrite + self.dry_run = dry_run + self.sync_usage = sync_usage + + +def current_month(): + return _dt.date.today().strftime("%Y-%m") + + +def key_id(api_key): + return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16] + + +def read_json(path, default): + if not os.path.exists(path): + return default + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + + +def write_json(path, data): + directory = os.path.dirname(os.path.abspath(path)) + if directory and not os.path.exists(directory): + os.makedirs(directory) + fd, tmp_path = tempfile.mkstemp(prefix=".tinify-", suffix=".json", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, sort_keys=True, ensure_ascii=False) + fh.write("\n") + os.replace(tmp_path, path) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def init_config(path): + if os.path.exists(path): + raise SystemExit("配置文件已存在:{0}".format(path)) + sample = { + "api_keys": [ + {"name": "账号-1", "key": "YOUR_TINIFY_API_KEY_1", "monthly_limit": DEFAULT_LIMIT}, + {"name": "账号-2", "key": "YOUR_TINIFY_API_KEY_2", "monthly_limit": DEFAULT_LIMIT}, + ], + "proxy": None, + } + write_json(path, sample) + print("已创建配置模板:{0}".format(path)) + + +def load_tinify(): + try: + import tinify + except ImportError as exc: + raise SystemExit("缺少依赖:{0}。请使用 uv run tinypng_gui.py 或 uv run tinypng_balancer.py 运行。".format(exc)) + return tinify + + +def load_keys(config_path): + config = read_json(config_path, None) + if not config: + raise SystemExit("找不到配置文件。请先执行:--init-config {0}".format(config_path)) + + entries = config.get("api_keys", []) + keys = [] + for index, item in enumerate(entries): + if isinstance(item, str): + item = {"key": item} + api_key = (item.get("key") or "").strip() + if not api_key or api_key.startswith("YOUR_TINIFY_API_KEY"): + continue + keys.append( + { + "id": key_id(api_key), + "key": api_key, + "name": item.get("name") or "key-{0}".format(index + 1), + "monthly_limit": int(item.get("monthly_limit") or DEFAULT_LIMIT), + } + ) + if not keys: + raise SystemExit("配置文件中没有可用的 API Key:{0}".format(config_path)) + return config, keys + + +def load_state(path): + state = read_json(path, {}) + month = current_month() + if state.get("month") != month: + state = {"month": month, "usage": {}} + state.setdefault("usage", {}) + return state + + +def get_count(state, candidate): + return int(state["usage"].get(candidate["id"], 0)) + + +def set_count(state, candidate, count): + state["usage"][candidate["id"]] = int(count) + + +def choose_key(keys, state, blocked): + # 选择本月用量最少、且还没有达到月度上限的 API Key。 + available = [ + key + for key in keys + if not key.get("invalid") + and key["id"] not in blocked + and get_count(state, key) < key["monthly_limit"] + ] + if not available: + return None + return min(available, key=lambda item: (get_count(state, item), item["name"])) + + +def sync_key_usage(tinify, keys, state, state_path, config, emit): + """调用官方 validate 接口,同步每个 API Key 的本月真实用量。""" + emit("正在同步官方本月用量...") + for candidate in keys: + tinify.key = candidate["key"] + tinify.proxy = config.get("proxy") + try: + tinify.validate() + if tinify.compression_count is not None: + set_count(state, candidate, tinify.compression_count) + emit( + "额度 {0}: {1}/{2}".format( + candidate["name"], + get_count(state, candidate), + candidate["monthly_limit"], + ) + ) + except tinify.AccountError as exc: + candidate["invalid"] = True + emit("禁用 {0}: {1}".format(candidate["name"], exc)) + except (tinify.ConnectionError, tinify.ServerError) as exc: + emit("同步失败 {0}: {1}".format(candidate["name"], exc)) + raise + write_json(state_path, state) + + +def collect_inputs(inputs, recursive): + files = [] + roots = [] + for path in inputs: + path = os.path.abspath(path) + if os.path.isdir(path): + roots.append(path) + if recursive: + for base, _, names in os.walk(path): + for name in names: + full_path = os.path.join(base, name) + if full_path.lower().endswith(SUPPORTED_EXTENSIONS): + files.append((full_path, path)) + else: + for name in os.listdir(path): + full_path = os.path.join(path, name) + if os.path.isfile(full_path) and full_path.lower().endswith(SUPPORTED_EXTENSIONS): + files.append((full_path, path)) + elif os.path.isfile(path): + files.append((path, os.path.dirname(path))) + else: + raise SystemExit("输入路径不存在:{0}".format(path)) + return files, roots + + +def output_path_for(input_path, root, output, total_files, convert_to): + output = os.path.abspath(output) + _, original_ext = os.path.splitext(input_path) + final_ext = CONVERT_TYPES[convert_to][1] if convert_to else original_ext + + if total_files == 1 and not os.path.isdir(output) and os.path.splitext(output)[1]: + return output + + relative = os.path.relpath(input_path, root) + relative_base, _ = os.path.splitext(relative) + return os.path.join(output, relative_base + final_ext) + + +def convert_targets(convert_to): + if convert_to == CONVERT_ALL: + return list(CONVERT_ALL_TARGETS) + return [convert_to] + + +def args_for_convert(args, convert_to): + return BatchArgs( + resize_method=args.resize_method, + width=args.width, + height=args.height, + convert=convert_to, + recursive=args.recursive, + overwrite=args.overwrite, + dry_run=args.dry_run, + sync_usage=args.sync_usage, + ) + + +def build_source(tinify, input_path, args): + source = tinify.from_file(input_path) + if args.resize_method: + options = {"method": args.resize_method} + if args.width: + options["width"] = args.width + if args.height: + options["height"] = args.height + source = source.resize(**options) + if args.convert: + source = source.convert(type=[CONVERT_TYPES[args.convert][0]]) + return source + + +def optimize_one(tinify, input_path, output_path, keys, state, state_path, config, args): + blocked = set() + last_error = None + + while True: + candidate = choose_key(keys, state, blocked) + if not candidate: + raise RuntimeError("所有 API Key 都已达到本月上限或暂不可用。最后错误:{0}".format(last_error)) + + tinify.key = candidate["key"] + tinify.proxy = config.get("proxy") + + try: + directory = os.path.dirname(os.path.abspath(output_path)) + if directory and not os.path.exists(directory): + os.makedirs(directory) + build_source(tinify, input_path, args).to_file(output_path) + if tinify.compression_count is not None: + set_count(state, candidate, tinify.compression_count) + else: + set_count(state, candidate, get_count(state, candidate) + 1) + write_json(state_path, state) + return candidate + except tinify.AccountError as exc: + last_error = exc + if getattr(exc, "status", None) == 429: + if tinify.compression_count is not None: + set_count(state, candidate, tinify.compression_count) + else: + set_count(state, candidate, candidate["monthly_limit"]) + write_json(state_path, state) + blocked.add(candidate["id"]) + except (tinify.ConnectionError, tinify.ServerError) as exc: + last_error = exc + blocked.add(candidate["id"]) + + +def run_batch(inputs, output, config_path, state_path, args, progress=None): + """执行批量压缩;progress 回调用于 GUI 实时显示日志。""" + + def emit(message): + if progress: + progress(message) + else: + print(message) + + config, keys = load_keys(config_path) + state = load_state(state_path) + files, _ = collect_inputs(inputs, args.recursive) + if not files: + raise SystemExit("没有找到支持的图片文件。") + + tinify = None if args.dry_run else load_tinify() + if tinify and args.sync_usage: + sync_key_usage(tinify, keys, state, state_path, config, emit) + + emit("月份:{0}".format(state["month"])) + emit("API Key 数量:{0}".format(len(keys))) + emit("图片数量:{0}".format(len(files))) + targets = convert_targets(args.convert) + if args.convert == CONVERT_ALL: + emit("转换格式:png/jpeg/webp/avif 全部输出") + + ok = 0 + skipped = 0 + failed = 0 + total_outputs = len(files) * len(targets) + + for input_path, root in files: + for target in targets: + item_args = args_for_convert(args, target) + output_path = output_path_for(input_path, root, output, total_outputs, target) + if os.path.exists(output_path) and not args.overwrite: + skipped += 1 + emit("跳过 {0} -> {1}".format(input_path, output_path)) + continue + if args.dry_run: + emit("计划 {0} -> {1}".format(input_path, output_path)) + continue + try: + used = optimize_one(tinify, input_path, output_path, keys, state, state_path, config, item_args) + ok += 1 + emit( + "完成 {0} -> {1} [{2}: {3}/{4}]".format( + input_path, + output_path, + used["name"], + get_count(state, used), + used["monthly_limit"], + ) + ) + except Exception as exc: + failed += 1 + emit("失败 {0}: {1}".format(input_path, exc)) + + if args.dry_run: + emit("试运行完成。") + else: + write_json(state_path, state) + emit("处理完成。成功={0},跳过={1},失败={2}".format(ok, skipped, failed)) + + return {"ok": ok, "skipped": skipped, "failed": failed} + + +def parse_args(argv): + parser = argparse.ArgumentParser( + description="使用无限个 Tinify API Key 进行图片压缩/转换,并按每月额度自动负载均衡。" + ) + parser.add_argument("inputs", nargs="*", help="图片文件或目录。") + parser.add_argument("-o", "--output", default="optimized", help="输出文件或目录。默认:optimized") + parser.add_argument("--config", default="tinify_keys.json", help="API Key 配置 JSON。") + parser.add_argument("--state", default="tinify_usage.json", help="每月用量记录 JSON。") + parser.add_argument("--init-config", metavar="PATH", help="创建配置模板后退出。") + parser.add_argument("--recursive", action="store_true", help="递归扫描目录。") + parser.add_argument("--overwrite", action="store_true", help="覆盖已存在的输出文件。") + parser.add_argument("--convert", choices=sorted(CONVERT_TYPES) + [CONVERT_ALL], help="转换输出格式,all 表示全部格式。") + parser.add_argument("--resize-method", choices=("scale", "fit", "cover", "thumb"), help="Tinify 缩放方式。") + parser.add_argument("--width", type=int, help="缩放宽度。") + parser.add_argument("--height", type=int, help="缩放高度。") + parser.add_argument("--dry-run", action="store_true", help="只显示计划,不调用 Tinify。") + parser.add_argument("--no-sync-usage", action="store_true", help="跳过启动前的官方额度同步。") + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv or sys.argv[1:]) + + if args.init_config: + init_config(args.init_config) + return 0 + + if not args.inputs: + raise SystemExit("请至少提供一个输入文件或目录。") + if args.resize_method and not (args.width or args.height): + raise SystemExit("--resize-method 需要同时提供 --width 和/或 --height。") + + batch_args = BatchArgs( + resize_method=args.resize_method, + width=args.width, + height=args.height, + convert=args.convert, + recursive=args.recursive, + overwrite=args.overwrite, + dry_run=args.dry_run, + sync_usage=not args.no_sync_usage, + ) + result = run_batch(args.inputs, args.output, args.config, args.state, batch_args) + return 1 if result["failed"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tinypng_gui.py b/tinypng_gui.py new file mode 100644 index 0000000..c41f27b --- /dev/null +++ b/tinypng_gui.py @@ -0,0 +1,366 @@ +#!/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()