支持多选转换格式和顺序使用 API Key

main
MAOMOMO 2026-05-23 16:17:15 +08:00
parent 8a18f1df9b
commit adca311c0a
3 changed files with 69 additions and 33 deletions

View File

@ -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

View File

@ -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="缩放高度。")

View File

@ -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,