朋友做的一个脚本,相对于互联网上其他导出工具更方便快捷,支持多线程导出
使用时可自行修改导出json的配置,导出路径结构保持输入路径的.spine文件的相对位置
import os
import json
import subprocess
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk, simpledialog
CONFIG_FILE = "config.json"
running = False
def browse_directory(entry):
directory = filedialog.askdirectory()
if directory:
entry.delete(0, tk.END)
entry.insert(0, directory)
def browse_file(entry):
file_path = filedialog.askopenfilename(filetypes=[("Spine", "Spine.com")])
if file_path:
entry.delete(0, tk.END)
entry.insert(0, file_path)
def generate_json_files():
spine_dir = entry_spine_dir.get()
output_dir = entry_output_dir.get()
json_save_dir = entry_json_save_dir.get()
if not os.path.isdir(spine_dir) or not os.path.isdir(output_dir) or not os.path.isdir(json_save_dir):
messagebox.showerror("路径错误", "请确认所有路径都已正确填写!")
return
existing_jsons = [f for f in os.listdir(json_save_dir) if f.endswith(".json")]
if existing_jsons:
confirm = messagebox.askyesno("确认操作", f"检测到已有 {len(existing_jsons)} 个 JSON 文件,是否删除后重新生成?")
if confirm:
try:
for f in existing_jsons:
os.remove(os.path.join(json_save_dir, f))
except Exception as e:
messagebox.showerror("删除失败", f"删除 JSON 文件时发生错误: {e}")
return
else:
messagebox.showinfo("已取消", "未删除,取消生成。")
return
spine_files = []
for root, _, files in os.walk(spine_dir):
for file in files:
if file.endswith(".spine"):
abs_path = os.path.abspath(os.path.join(root, file))
rel_path = os.path.relpath(root, spine_dir)
spine_files.append((abs_path, rel_path))
if not spine_files:
messagebox.showinfo("无结果", "没有找到 .spine 文件!")
return
listbox_spine_files.delete(0, tk.END)
for abs_path, _ in spine_files:
listbox_spine_files.insert(tk.END, abs_path)
for idx, (abs_path, rel_path) in enumerate(spine_files, 1):
json_data = {
"class": "export-json",
"extension": ".json",
"format": "JSON",
"prettyPrint": True,
"nonessential": True,
"cleanUp": True,
"packAtlas": {
"stripWhitespaceX": True,
"stripWhitespaceY": True,
"rotation": True,
"alias": True,
"ignoreBlankImages": False,
"alphaThreshold": 3,
"minWidth": 16,
"minHeight": 16,
"maxWidth": 8192,
"maxHeight": 8129,
"pot": False,
"multipleOfFour": False,
"square": True,
"outputFormat": "png",
"jpegQuality": 0.9,
"premultiplyAlpha": True,
"bleed": False,
"scale": [1],
"scaleSuffix": [""],
"scaleResampling": ["bicubic"],
"paddingX": 2,
"paddingY": 2,
"edgePadding": True,
"duplicatePadding": False,
"filterMin": "Linear",
"filterMag": "Linear",
"wrapX": "ClampToEdge",
"wrapY": "ClampToEdge",
"format": "RGBA8888",
"atlasExtension": ".atlas",
"combineSubdirectories": False,
"flattenPaths": False,
"useIndexes": False,
"debug": False,
"fast": False,
"limitMemory": True,
"currentProject": True,
"packing": "rectangles",
"prettyPrint": True,
"legacyOutput": False,
"webp": None,
"autoScale": True,
"bleedIterations": 2,
"id": -1,
"ignore": False,
"separator": "_",
"silent": False
},
"packSource": "attachments",
"packTarget": "single",
"warnings": False,
"version": None,
"all": True,
"output": os.path.join(output_dir, rel_path),
"id": -1,
"input": abs_path,
"open": False
}
json_file_path = os.path.join(json_save_dir, f"{idx}.json")
with open(json_file_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, indent=4)
messagebox.showinfo("完成", f"成功生成 {len(spine_files)} 个 JSON 文件!")
def run_spine_commands_thread():
global running
spine_exe = entry_spine_exe.get()
version = entry_version.get()
workers = entry_workers.get()
json_dir = entry_json_save_dir.get()
if not os.path.isfile(spine_exe):
messagebox.showerror("路径错误", "spine.com 路径无效!")
return
if not os.path.isdir(json_dir):
messagebox.showerror("路径错误", "JSON保存目录无效!")
return
if not version:
messagebox.showerror("缺少信息", "请填写版本号!")
return
try:
workers = int(workers)
if workers <= 0 or workers > 20: # 限制线程数在1-20之间
messagebox.showerror("无效输入", "线程数必须是1-20之间的整数!")
return
except ValueError:
messagebox.showerror("无效输入", "线程数必须是有效的整数!")
return
json_files = [f for f in os.listdir(json_dir) if f.endswith(".json")]
if not json_files:
messagebox.showinfo("无结果", "JSON目录下没有可执行的 JSON 文件!")
return
log_text.delete(1.0, tk.END)
running = True
btn_run.config(text="终止执行", command=stop_commands)
progress_bar['maximum'] = len(json_files)
progress_bar['value'] = 0
progress_label.config(text=f"0/{len(json_files)}")
import queue
ui_update_queue = queue.Queue()
ui_update_queue.put(("workers", f"\n[{workers}]\n"))
def update_ui_from_queue():
while not ui_update_queue.empty():
update_type, data = ui_update_queue.get_nowait()
if update_type == "log":
log_text.insert(tk.END, data)
log_text.see(tk.END)
elif update_type == "progress":
progress_bar['value'] = data
progress_label.config(text=f"{data}/{len(json_files)}")
elif update_type == "file_status":
idx, status, color_bg, color_fg = data
current_text = listbox_spine_files.get(idx)
if "✅已完成" not in current_text and "❌失败" not in current_text:
listbox_spine_files.delete(idx)
listbox_spine_files.insert(idx, f"{current_text} {status}")
listbox_spine_files.itemconfig(idx, {'bg': color_bg, 'fg': color_fg})
root.after(100, update_ui_from_queue)
root.after(100, update_ui_from_queue)
def process_json_file(json_file):
nonlocal outdone
if not running:
return False
json_path = os.path.abspath(os.path.join(json_dir, json_file))
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
input_path = data.get("input", "未知")
output_path = data.get("output", "未知")
ui_update_queue.put(("log", f"\n[{json_file}]\n"))
ui_update_queue.put(("log", f"Input: {input_path}\n"))
ui_update_queue.put(("log", f"Output: {output_path}\n"))
ui_update_queue.put(("log", f"command: \n"))
command = f'"{spine_exe}" -e "{json_path}" --update {version}'
ui_update_queue.put(("log", command + "\n"))
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
for line in process.stdout:
if not running:
process.terminate()
return False
ui_update_queue.put(("log", line))
process.wait()
for i in range(listbox_spine_files.size()):
item_text = listbox_spine_files.get(i)
if item_text.startswith(input_path):
if process.returncode == 0:
ui_update_queue.put(("file_status", (i, "✅已完成", "#d4edda", "#155724")))
ui_update_queue.put(("log", f"[完成]{json_file}\n"))
else:
ui_update_queue.put(("file_status", (i, "❌失败", "#f8d7da", "#721c24")))
ui_update_queue.put(("log", f"[失败]{json_file}(退出码 {process.returncode})\n"))
break
return process.returncode == 0
except Exception as e:
ui_update_queue.put(("log", f"处理 {json_file} 时发生错误: {e}\n"))
return False
outdone = 0
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(process_json_file, json_file): json_file for json_file in json_files}
for future in as_completed(futures):
if future.result():
outdone += 1
ui_update_queue.put(("progress", outdone))
if not running:
for f in futures:
f.cancel()
break
running = False
btn_run.config(text="执行 Spine 命令", command=start_run_commands)
ui_update_queue.put(("log", f"\n命令执行完毕。成功导出 {outdone}/{len(json_files)} 个文件。\n"))
messagebox.showinfo("完成", f"命令执行完毕。成功导出 {outdone}/{len(json_files)} 个文件。")
def start_run_commands():
threading.Thread(target=run_spine_commands_thread, daemon=True).start()
def stop_commands():
global running
running = False
def save_config():
config_name = config_combobox.get()
if not config_name:
messagebox.showerror("错误", "请选择一个配置或创建新配置!")
return
config_data = load_all_configs()
config_data[config_name] = {
"spine_dir": entry_spine_dir.get(),
"output_dir": entry_output_dir.get(),
"json_save_dir": entry_json_save_dir.get(),
"spine_exe": entry_spine_exe.get(),
"version": entry_version.get(),
"workers": entry_workers.get()
}
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=4)
update_config_combobox()
config_combobox.set(config_name)
messagebox.showinfo("保存成功", f"配置 '{config_name}' 已保存!")
def load_config():
config_name = config_combobox.get()
if not config_name:
return
config_data = load_all_configs()
if config_name not in config_data:
messagebox.showerror("错误", f"配置 '{config_name}' 不存在!")
return
config = config_data[config_name]
entry_spine_dir.delete(0, tk.END)
entry_spine_dir.insert(0, config.get("spine_dir", ""))
entry_output_dir.delete(0, tk.END)
entry_output_dir.insert(0, config.get("output_dir", ""))
entry_json_save_dir.delete(0, tk.END)
entry_json_save_dir.insert(0, config.get("json_save_dir", ""))
entry_spine_exe.delete(0, tk.END)
entry_spine_exe.insert(0, config.get("spine_exe", ""))
entry_version.delete(0, tk.END)
entry_version.insert(0, config.get("version", ""))
entry_workers.delete(0, tk.END)
entry_workers.insert(0, config.get("workers", "5")) # 默认线程数为5
messagebox.showinfo("加载成功", f"配置 '{config_name}' 已加载!")
def load_all_configs():
if not os.path.isfile(CONFIG_FILE):
return {"default": {
"spine_dir": "",
"output_dir": "",
"json_save_dir": "",
"spine_exe": "",
"version": "",
"workers": "5"
}}
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
try:
return json.load(f)
except json.JSONDecodeError:
return {"default": {
"spine_dir": "",
"output_dir": "",
"json_save_dir": "",
"spine_exe": "",
"version": "",
"workers": "5"
}}
def add_config():
new_name = simpledialog.askstring("添加配置", "请输入新配置名称:")
if not new_name:
return
config_data = load_all_configs()
if new_name in config_data:
messagebox.showerror("错误", f"配置 '{new_name}' 已存在!")
return
config_data[new_name] = {
"spine_dir": "",
"output_dir": "",
"json_save_dir": "",
"spine_exe": "",
"version": "",
"workers": "5"
}
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=4)
update_config_combobox()
config_combobox.set(new_name)
messagebox.showinfo("成功", f"配置 '{new_name}' 已添加!")
def delete_config():
config_name = config_combobox.get()
if not config_name:
return
if not messagebox.askyesno("确认删除", f"确定要删除配置 '{config_name}' 吗?"):
return
config_data = load_all_configs()
if config_name not in config_data:
messagebox.showerror("错误", f"配置 '{config_name}' 不存在!")
return
del config_data[config_name]
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=4)
update_config_combobox()
if config_data:
config_combobox.set(next(iter(config_data.keys())))
else:
config_data["default"] = {
"spine_dir": "",
"output_dir": "",
"json_save_dir": "",
"spine_exe": "",
"version": "",
"workers": "5"
}
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=4)
update_config_combobox()
config_combobox.set("default")
messagebox.showinfo("成功", f"配置 '{config_name}' 已删除!")
def update_config_combobox():
config_data = load_all_configs()
config_combobox['values'] = list(config_data.keys())
root = tk.Tk()
root.title("Spine 批量导出工具 By github:phtcloud_dev")
root.geometry("900x800")
# 配置管理区域
frame_config = tk.Frame(root, padx=10, pady=5)
frame_config.pack(fill="x")
tk.Label(frame_config, text="配置管理:").pack(side="left")
config_combobox = ttk.Combobox(frame_config, width=20)
config_combobox.pack(side="left", padx=5)
update_config_combobox()
tk.Button(frame_config, text="加载", command=load_config, width=6).pack(side="left", padx=2)
tk.Button(frame_config, text="保存", command=save_config, width=6).pack(side="left", padx=2)
tk.Button(frame_config, text="添加", command=add_config, width=6).pack(side="left", padx=2)
tk.Button(frame_config, text="删除", command=delete_config, width=6).pack(side="left", padx=2)
frame_inputs = tk.Frame(root, padx=10, pady=10)
frame_inputs.pack(fill="x")
def add_path_row(label, entry_var, browse_func):
frame = tk.Frame(frame_inputs)
frame.pack(fill="x", pady=4)
tk.Label(frame, text=label, width=15, anchor="w").pack(side="left")
entry = tk.Entry(frame, textvariable=entry_var, width=70)
entry.pack(side="left", padx=5)
tk.Button(frame, text="选择", command=lambda: browse_func(entry)).pack(side="left")
return entry
entry_spine_dir = add_path_row(".spine 目录:", tk.StringVar(), browse_directory)
entry_output_dir = add_path_row("输出目录:", tk.StringVar(), browse_directory)
entry_json_save_dir = add_path_row("JSON保存目录:", tk.StringVar(), browse_directory)
entry_spine_exe = add_path_row("spine.com路径:", tk.StringVar(), browse_file)
frame_version = tk.Frame(frame_inputs)
frame_version.pack(fill="x", pady=4)
tk.Label(frame_version, text="Spine 版本:", width=15, anchor="w").pack(side="left")
entry_version = tk.Entry(frame_version, width=8)
entry_version.pack(side="left", padx=5)
frame_workers = tk.Frame(frame_inputs)
frame_workers.pack(fill="x", pady=4)
tk.Label(frame_workers, text="线程数:", width=15, anchor="w").pack(side="left")
entry_workers = tk.Entry(frame_workers, width=8)
entry_workers.pack(side="left", padx=5)
entry_workers.insert(0, "5") # 默认线程数为5
frame_list = tk.Frame(root, padx=10, pady=10)
frame_list.pack(fill="both", expand=True)
tk.Label(frame_list, text="Spine 文件列表:").pack(anchor="w")
listbox_frame = tk.Frame(frame_list)
listbox_frame.pack(fill="both", expand=True)
listbox_spine_files = tk.Listbox(
listbox_frame,
bg="white",
fg="black",
selectbackground="#0078d7",
selectforeground="white",
font=("Microsoft YaHei", 10),
relief=tk.FLAT,
bd=2
)
listbox_spine_files.pack(side="left", fill="both", expand=True)
scrollbar = tk.Scrollbar(listbox_frame, orient="vertical", command=listbox_spine_files.yview)
scrollbar.pack(side="right", fill="y")
listbox_spine_files.config(yscrollcommand=scrollbar.set)
frame_buttons = tk.Frame(root, padx=10, pady=10)
frame_buttons.pack()
tk.Button(frame_buttons, text="生成 JSON 文件", command=generate_json_files, width=20).pack(side="left", padx=10)
btn_run = tk.Button(frame_buttons, text="执行 Spine 命令", command=start_run_commands, width=20)
btn_run.pack(side="left", padx=10)
frame_progress = tk.Frame(root, padx=10, pady=5)
frame_progress.pack(fill="x")
progress_label = tk.Label(frame_progress, text="0/0")
progress_label.pack(side="right")
progress_bar = ttk.Progressbar(frame_progress, orient="horizontal", length=800, mode="determinate")
progress_bar.pack(fill="x", expand=True)
frame_log = tk.Frame(root, padx=10, pady=10)
frame_log.pack(fill="both", expand=True)
tk.Label(frame_log, text="执行日志:").pack(anchor="w")
log_text = scrolledtext.ScrolledText(
frame_log,
height=10,
wrap=tk.WORD,
font=("Consolas", 10),
padx=5,
pady=5
)
log_text.pack(fill="both", expand=True)
if os.path.isfile(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
config_data = json.load(f)
if config_data:
config_combobox.set(next(iter(config_data.keys())))
load_config()
except:
pass
root.mainloop()