初始化

main
MAOMOMO 2026-05-23 15:58:46 +08:00
commit 8a18f1df9b
7 changed files with 1005 additions and 0 deletions

6
.gitignore vendored 100644
View File

@ -0,0 +1,6 @@
__pycache__
tinify_keys.json
tinify_usage.json
optimized
source
uv.lock

193
README.md 100644
View File

@ -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 额度。

8
pyproject.toml 100644
View File

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

3
run_gui.bat 100644
View File

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
uv run tinypng_gui.py

View File

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

414
tinypng_balancer.py 100644
View File

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

366
tinypng_gui.py 100644
View File

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