支持多选转换格式和顺序使用 API Key
parent
8a18f1df9b
commit
adca311c0a
15
README.md
15
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
|
||||
|
|
|
|||
|
|
@ -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="缩放高度。")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue