spine批量导出工具

朋友做的一个脚本,相对于互联网上其他导出工具更方便快捷,支持多线程导出
使用时可自行修改导出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()

json_data 是导出的配置文件
使用spine导出生成配置后参考修改相关参数即可
1748494668791