初始化
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