初始化
commit
8a18f1df9b
|
|
@ -0,0 +1,6 @@
|
|||
__pycache__
|
||||
tinify_keys.json
|
||||
tinify_usage.json
|
||||
optimized
|
||||
source
|
||||
uv.lock
|
||||
|
|
@ -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 额度。
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
uv run tinypng_gui.py
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue