From adca311c0af274f23482269c647d7367f79ae1d8 Mon Sep 17 00:00:00 2001 From: MAOMOMO Date: Sat, 23 May 2026 16:17:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E6=A0=BC=E5=BC=8F=E5=92=8C=E9=A1=BA=E5=BA=8F=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20API=20Key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++++++++---- tinypng_balancer.py | 34 +++++++++++++++++++++++------ tinypng_gui.py | 53 ++++++++++++++++++++++++++------------------- 3 files changed, 69 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 395479f..67d207f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # TinyPNG 批量压缩工具 -这是一个基于 Tinify/TinyPNG Python SDK 的批量图片压缩和格式转换工具。支持 GUI 界面、多个 API Key 负载均衡、官方额度同步、批量转换格式,以及递归处理目录。 +这是一个基于 Tinify/TinyPNG Python SDK 的批量图片压缩和格式转换工具。支持 GUI 界面、多个 API Key 按顺序接力使用、官方额度同步、批量转换格式,以及递归处理目录。 ## 功能 - 图形界面选择输入目录和输出目录。 -- 支持多个 Tinify API Key,每个 Key 默认每月 500 张额度。 +- 支持多个 Tinify API Key,每个 Key 默认每月 500 张额度,处理时会先用完前一个 Key,再切换到下一个。 - 启动处理前可同步官方本月已用额度。 - 支持 PNG、JPG/JPEG、WebP、AVIF 图片。 - 支持转换为 png、jpeg、webp、avif,也支持“全部格式”一次输出四种格式。 @@ -97,12 +97,13 @@ KEY_3 可选值: -- `保持原格式`:只压缩,不改变格式。 +- `源格式`:只压缩,不改变格式。 - `png`:输出 PNG。 - `jpeg` / `jpg`:输出 JPEG。 - `webp`:输出 WebP。 - `avif`:输出 AVIF。 -- `全部格式`:每张图片分别输出 `.png`、`.jpg`、`.webp`、`.avif`。 +- 可多选,例如默认会同时选择 `源格式` 和 `webp`,每张图片会输出源格式压缩结果和 WebP 结果。 +- 命令行的 `all` 会输出源格式、`.png`、`.jpg`、`.webp`、`.avif`。 注意:转换、缩放等 Tinify API 操作可能会消耗压缩额度。“全部格式”会为每张输入图片生成多个输出,因此额度消耗也会更多。 @@ -140,6 +141,12 @@ uv run tinypng_balancer.py source -o optimized --recursive uv run tinypng_balancer.py source -o optimized --convert webp ``` +同时输出源格式和 WebP: + +```powershell +uv run tinypng_balancer.py source -o optimized --convert source --convert webp +``` + 输出全部格式: ```powershell diff --git a/tinypng_balancer.py b/tinypng_balancer.py index cb18912..c2bdc4d 100644 --- a/tinypng_balancer.py +++ b/tinypng_balancer.py @@ -26,6 +26,7 @@ CONVERT_TYPES = { "webp": ("image/webp", ".webp"), "avif": ("image/avif", ".avif"), } +CONVERT_SOURCE = "source" CONVERT_ALL = "all" CONVERT_ALL_TARGETS = ("png", "jpeg", "webp", "avif") @@ -150,7 +151,7 @@ def set_count(state, candidate, count): def choose_key(keys, state, blocked): - # 选择本月用量最少、且还没有达到月度上限的 API Key。 + # 按配置顺序使用 API Key:先用完第一个,再切到下一个。 available = [ key for key in keys @@ -160,7 +161,7 @@ def choose_key(keys, state, blocked): ] if not available: return None - return min(available, key=lambda item: (get_count(state, item), item["name"])) + return available[0] def sync_key_usage(tinify, keys, state, state_path, config, emit): @@ -228,8 +229,19 @@ def output_path_for(input_path, root, output, total_files, convert_to): def convert_targets(convert_to): + if not convert_to: + return [CONVERT_SOURCE] + if isinstance(convert_to, (list, tuple)): + targets = [] + for item in convert_to: + for target in convert_targets(item): + if target not in targets: + targets.append(target) + return targets + if convert_to == CONVERT_SOURCE: + return [CONVERT_SOURCE] if convert_to == CONVERT_ALL: - return list(CONVERT_ALL_TARGETS) + return [CONVERT_SOURCE] + list(CONVERT_ALL_TARGETS) return [convert_to] @@ -255,7 +267,7 @@ def build_source(tinify, input_path, args): if args.height: options["height"] = args.height source = source.resize(**options) - if args.convert: + if args.convert and args.convert != CONVERT_SOURCE: source = source.convert(type=[CONVERT_TYPES[args.convert][0]]) return source @@ -321,7 +333,9 @@ def run_batch(inputs, output, config_path, state_path, args, progress=None): emit("图片数量:{0}".format(len(files))) targets = convert_targets(args.convert) if args.convert == CONVERT_ALL: - emit("转换格式:png/jpeg/webp/avif 全部输出") + emit("转换格式:源格式/png/jpeg/webp/avif 全部输出") + else: + emit("转换格式:{0}".format(", ".join("源格式" if item == CONVERT_SOURCE else item for item in targets))) ok = 0 skipped = 0 @@ -331,7 +345,8 @@ def run_batch(inputs, output, config_path, state_path, args, progress=None): 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) + output_convert = None if target == CONVERT_SOURCE else target + output_path = output_path_for(input_path, root, output, total_outputs, output_convert) if os.path.exists(output_path) and not args.overwrite: skipped += 1 emit("跳过 {0} -> {1}".format(input_path, output_path)) @@ -375,7 +390,12 @@ def parse_args(argv): 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( + "--convert", + action="append", + choices=[CONVERT_SOURCE] + sorted(CONVERT_TYPES) + [CONVERT_ALL], + help="转换输出格式,可重复传入;source 表示源格式,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="缩放高度。") diff --git a/tinypng_gui.py b/tinypng_gui.py index c41f27b..8b6c3d9 100644 --- a/tinypng_gui.py +++ b/tinypng_gui.py @@ -8,7 +8,7 @@ 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 +from tinypng_balancer import BatchArgs, CONVERT_SOURCE, CONVERT_TYPES, run_batch, write_json BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -148,7 +148,13 @@ class TinypngApp(tk.Tk): 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.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="") @@ -184,16 +190,16 @@ class TinypngApp(tk.Tk): 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)) + 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=2, sticky="w", pady=(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, @@ -201,12 +207,12 @@ class TinypngApp(tk.Tk): state="readonly", width=12, ) - resize_box.grid(row=1, column=3, sticky="w", pady=(10, 0)) + resize_box.grid(row=1, column=5, 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)) + 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)) @@ -231,6 +237,11 @@ class TinypngApp(tk.Tk): 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: @@ -304,12 +315,10 @@ class TinypngApp(tk.Tk): messagebox.showwarning("参数错误", "选择缩放方式后,请填写宽度或高度。") return - if self.convert_var.get() == "保持原格式": - convert = None - elif self.convert_var.get() == "全部格式": - convert = CONVERT_ALL - else: - convert = self.convert_var.get() + 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,