spine连接器和导出修改

This commit is contained in:
tianmo
2026-09-05 16:20:23 +08:00
parent 535f8d7558
commit 0bde824c88
81 changed files with 11800 additions and 1251 deletions
+19
View File
@@ -0,0 +1,19 @@
# Spine粒子连接器(独立版)
此文件夹内的连接器已包含运行所需的 Node 运行时,**无需安装 Node.js**。
## 使用
1. 正常安装并激活 Spine;连接器不会包含或替代 Spine 软件。
2. macOS 双击 `启动Spine连接器.command`Windows 双击 `启动Spine连接器.bat`
3. 回到网页,等待导出区域显示“连接器可用 · Spine 已就绪”。
4. 首次使用生产网站时,点击网页中的“授权当前网站”,在打开的本机页面确认完整域名后点击“允许此网站”。
5. 不使用时双击对应的“停止”脚本。
连接器仅监听 `127.0.0.1:27843`,不会对局域网或公网开放。授权按完整来源区分,例如 `https://editor.example.com``https://editor.example.com:8443` 需要分别授权。
## 分发说明
- 请保持整个文件夹完整,不能只复制可执行文件;启停脚本和说明文件也应一起分发。也可以直接分发同级生成的 ZIP 压缩包。
- macOS 版本只适用于其标记的 CPU 架构(Apple Silicon 或 Intel)。Windows 版本也需在 Windows 上构建。
- 独立程序已进行本机临时签名,能直接运行;面向其他 Mac 分发时,建议使用 Apple Developer ID 重新签名并公证,避免 Gatekeeper 提示。
+69
View File
@@ -0,0 +1,69 @@
# Spine粒子连接器
该连接器让部署在服务器上的 Spine粒子编辑器网页调用用户电脑中已安装、已激活的 Spine,把网页生成的 JSON 和图片转换成 `.spine` 工程。
## 使用方法
1. 正常安装、激活 Spine。
2. 使用 `release/connector/` 中的独立版时,macOS 双击 `启动Spine连接器.command`Windows 双击 `启动Spine连接器.bat`。独立版已内置运行环境,不需要安装 Node.js。
3. 打开 Spine粒子编辑器网页。导出区域显示“连接器可用 · Spine 已就绪”后,点击“导出 .spine 包”。
4. 服务器网站首次连接时,状态栏会显示“授权当前网站”。点击后会打开本机确认页,核对显示的完整网站来源(协议、域名和端口)后点击“允许此网站”。授权一次后,连接器会在本机保存允许列表;回到网页后最多等待 3 秒或点击“刷新”。
本地开发地址 `127.0.0.1:5173``localhost:5173` 默认允许,不需要授权。
授权按完整来源区分:`https://editor.example.com``http://editor.example.com``https://editor.example.com:8443` 是三个不同的授权项。生产环境建议始终使用 HTTPS 与固定域名。
网页下载的是 ZIP 压缩包,内部包含 `.spine` 工程和 JSON 引用的图片目录。浏览器不能直接下载文件夹,因此使用 ZIP 保留完整目录结构;默认结构为:
```text
SpineParticle.zip
├── SpineParticle.spine
└── images/
└── star.png
```
## 自动版本选择
连接器读取网页请求及 JSON 内的版本,两者必须一致。支持 `3.8``4.0``4.1``4.2``4.3`,调用 Spine 时分别使用 `3.8.xx``4.3.xx` 的最新可用补丁版本。
可通过环境变量覆盖自动找到的 Spine 路径:
```text
SPINE_EXECUTABLE=/自定义路径/Spine
```
也可以通过逗号分隔的环境变量预先允许生产网站:
```text
SPINE_CONNECTOR_ORIGINS=https://particle.example.com,https://editor.example.com
```
## 安全边界
- 服务只监听 `127.0.0.1:27843`,不会暴露到局域网或公网。
- 生产网站必须由用户在本机授权,授权结果保存在浏览器之外的连接器配置中。
- 接口只接受 JSON、图片、项目名称和受支持的 Spine 版本,不能执行网页提供的任意命令。
- 文件在系统临时目录转换,返回 `.spine` 后立即清理。
- 单个 JSON 最大 64MB,单图最大 32MB,图片总计最大 128MB,请求最大 200MB。
## 手动启动
在项目目录运行:
```text
npm run connector
```
健康检查地址:`http://127.0.0.1:27843/v1/health`
## 生成不依赖 Node.js 的独立版
在目标操作系统上从项目根目录执行:
```text
npm run build:connector
```
会生成 `release/connector/<系统-架构>/` 以及同目录下的 ZIP 分发包。把整个文件夹或 ZIP 交给用户即可,里面包含独立可执行文件和不依赖 Node.js 的启停脚本。构建必须在对应系统上完成:例如 Apple Silicon、Intel Mac、Windows 分别构建各自的版本;Node SEA 不支持把当前 Mac 可执行文件直接交叉打成 Windows `.exe`
构建后的 macOS 程序会进行临时本机签名。若要对外正式发布,仍建议使用 Apple Developer ID 重新签名并公证。
+50
View File
@@ -0,0 +1,50 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir, platform } from 'node:os'
import { dirname, join } from 'node:path'
function configPath() {
if (process.env.SPINE_CONNECTOR_CONFIG) return process.env.SPINE_CONNECTOR_CONFIG
if (platform() === 'darwin') return join(homedir(), 'Library/Application Support/SpineParticleConnector/config.json')
if (platform() === 'win32') return join(process.env.APPDATA || homedir(), 'SpineParticleConnector/config.json')
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'spine-particle-connector/config.json')
}
const localOrigins = new Set([
'http://127.0.0.1:5173',
'http://localhost:5173',
'http://127.0.0.1:4173',
'http://localhost:4173',
])
export async function loadConfig() {
try {
const parsed = JSON.parse(await readFile(configPath(), 'utf8'))
return { allowedOrigins: Array.isArray(parsed.allowedOrigins) ? parsed.allowedOrigins.filter((item) => typeof item === 'string') : [] }
} catch {
return { allowedOrigins: [] }
}
}
export async function allowOrigin(origin) {
const config = await loadConfig()
if (!config.allowedOrigins.includes(origin)) config.allowedOrigins.push(origin)
const path = configPath()
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
}
export async function originAllowed(origin) {
if (!origin) return true
if (localOrigins.has(origin)) return true
if (String(process.env.SPINE_CONNECTOR_ORIGINS || '').split(',').map((item) => item.trim()).includes(origin)) return true
return (await loadConfig()).allowedOrigins.includes(origin)
}
export function validWebOrigin(value) {
try {
const url = new URL(value)
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin === value ? url.origin : null
} catch {
return null
}
}
@@ -0,0 +1,35 @@
#!/bin/zsh
set -u
PACKAGE_DIR="${0:A:h}"
PID_FILE="$PACKAGE_DIR/.run/spine-connector.pid"
CONNECTOR_BIN="$PACKAGE_DIR/SpineParticleConnector"
CONNECTOR_PID=""
if [[ -f "$PID_FILE" ]]; then CONNECTOR_PID="$(tr -dc '0-9' < "$PID_FILE")"; fi
if [[ -z "$CONNECTOR_PID" ]] || ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then
CONNECTOR_PID="$(lsof -tiTCP:27843 -sTCP:LISTEN 2>/dev/null | head -n 1)"
fi
if [[ -z "$CONNECTOR_PID" ]] || ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then
rm -f "$PID_FILE"
echo "Spine粒子连接器已经停止。"
exit 0
fi
COMMAND="$(ps -p "$CONNECTOR_PID" -o command= 2>/dev/null)"
if [[ "$COMMAND" != *"$CONNECTOR_BIN"* ]] && [[ "$COMMAND" != *"SpineParticleConnector"* ]]; then
echo "27843 端口对应的进程不是此独立连接器,为避免误关,本次未执行停止。"
rm -f "$PID_FILE"
exit 1
fi
kill "$CONNECTOR_PID" 2>/dev/null || true
for _ in {1..30}; do
if ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then break; fi
sleep 0.1
done
if kill -0 "$CONNECTOR_PID" 2>/dev/null; then kill -KILL "$CONNECTOR_PID" 2>/dev/null || true; fi
rm -f "$PID_FILE"
echo "Spine粒子连接器已停止。"
@@ -0,0 +1,51 @@
#!/bin/zsh
set -u
PACKAGE_DIR="${0:A:h}"
RUN_DIR="$PACKAGE_DIR/.run"
PID_FILE="$RUN_DIR/spine-connector.pid"
LOG_FILE="$RUN_DIR/spine-connector.log"
CONNECTOR_BIN="$PACKAGE_DIR/SpineParticleConnector"
HEALTH_URL="http://127.0.0.1:27843/v1/health"
mkdir -p "$RUN_DIR"
if [[ ! -x "$CONNECTOR_BIN" ]]; then
echo "未找到独立连接器:$CONNECTOR_BIN"
read "?按回车键关闭窗口..."
exit 1
fi
if [[ -f "$PID_FILE" ]]; then
CONNECTOR_PID="$(tr -dc '0-9' < "$PID_FILE")"
if [[ -n "$CONNECTOR_PID" ]] && kill -0 "$CONNECTOR_PID" 2>/dev/null; then
echo "Spine粒子连接器已经在运行。"
exit 0
fi
rm -f "$PID_FILE"
fi
echo "正在启动 Spine粒子连接器..."
nohup "$CONNECTOR_BIN" > "$LOG_FILE" 2>&1 < /dev/null &!
CONNECTOR_PID=$!
echo "$CONNECTOR_PID" > "$PID_FILE"
for _ in {1..40}; do
if ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then
echo "连接器启动失败:"
tail -n 30 "$LOG_FILE"
rm -f "$PID_FILE"
read "?按回车键关闭窗口..."
exit 1
fi
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
echo "Spine粒子连接器启动成功,可以回到网页直接导出 .spine。"
exit 0
fi
sleep 0.25
done
echo "连接器启动超时,请查看日志:$LOG_FILE"
read "?按回车键关闭窗口..."
exit 1
@@ -0,0 +1,27 @@
@echo off
chcp 65001 >nul
setlocal
set "PACKAGE_DIR=%~dp0"
set "PID_FILE=%PACKAGE_DIR%.run\spine-connector.pid"
set "CONNECTOR_BIN=%PACKAGE_DIR%SpineParticleConnector.exe"
set "CONNECTOR_PID="
if exist "%PID_FILE%" set /p CONNECTOR_PID=<"%PID_FILE%"
powershell -NoProfile -Command ^
"$pidValue=0; [void][int]::TryParse($env:CONNECTOR_PID,[ref]$pidValue);" ^
"$process=if($pidValue -gt 0){Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $pidValue) -ErrorAction SilentlyContinue};" ^
"if($null -eq $process){$listener=Get-NetTCPConnection -LocalPort 27843 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if($listener){$process=Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $listener.OwningProcess) -ErrorAction SilentlyContinue}};" ^
"if ($null -eq $process) { exit 2 };" ^
"if ($process.CommandLine -notlike ('*' + $env:CONNECTOR_BIN + '*')) { exit 3 };" ^
"Stop-Process -Id $process.ProcessId -Force"
set "STOP_RESULT=%ERRORLEVEL%"
del /q "%PID_FILE%" >nul 2>&1
if "%STOP_RESULT%"=="3" (
echo 27843 端口对应的进程不是此独立连接器,为避免误关,本次未执行停止。
pause
exit /b 1
)
echo Spine粒子连接器已停止。
exit /b 0
@@ -0,0 +1,42 @@
@echo off
chcp 65001 >nul
setlocal
set "PACKAGE_DIR=%~dp0"
set "RUN_DIR=%PACKAGE_DIR%.run"
set "PID_FILE=%RUN_DIR%\spine-connector.pid"
set "OUT_LOG=%RUN_DIR%\spine-connector.log"
set "ERROR_LOG=%RUN_DIR%\spine-connector-error.log"
set "CONNECTOR_BIN=%PACKAGE_DIR%SpineParticleConnector.exe"
if not exist "%RUN_DIR%" mkdir "%RUN_DIR%"
if not exist "%CONNECTOR_BIN%" (
echo 未找到独立连接器:%CONNECTOR_BIN%
pause
exit /b 1
)
if exist "%PID_FILE%" (
set /p CONNECTOR_PID=<"%PID_FILE%"
powershell -NoProfile -Command "if (Get-Process -Id $env:CONNECTOR_PID -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"
if not errorlevel 1 (
echo Spine粒子连接器已经在运行。
exit /b 0
)
del /q "%PID_FILE%" >nul 2>&1
)
echo 正在启动 Spine粒子连接器...
powershell -NoProfile -Command ^
"$process=Start-Process -FilePath $env:CONNECTOR_BIN -WorkingDirectory $env:PACKAGE_DIR -RedirectStandardOutput $env:OUT_LOG -RedirectStandardError $env:ERROR_LOG -WindowStyle Hidden -PassThru;" ^
"Set-Content -Path $env:PID_FILE -Value $process.Id -Encoding ascii"
if errorlevel 1 (
echo 连接器启动失败。
pause
exit /b 1
)
timeout /t 2 /nobreak >nul
echo Spine粒子连接器已启动,可以回到网页直接导出 .spine。
exit /b 0
+75
View File
@@ -0,0 +1,75 @@
import { access, stat } from 'node:fs/promises'
import { constants } from 'node:fs'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
const SUPPORTED_VERSIONS = new Set(['3.8', '4.0', '4.1', '4.2', '4.3'])
async function executable(path) {
if (!path) return false
try {
await access(path, platform() === 'win32' ? constants.F_OK : constants.X_OK)
return true
} catch {
return false
}
}
export async function findSpineExecutable() {
const candidates = []
if (process.env.SPINE_EXECUTABLE) candidates.push(process.env.SPINE_EXECUTABLE)
if (platform() === 'darwin') {
candidates.push(
'/Applications/Spine.app/Contents/MacOS/Spine',
join(homedir(), 'Applications/Spine.app/Contents/MacOS/Spine'),
)
} else if (platform() === 'win32') {
const programFiles = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean)
for (const root of programFiles) {
candidates.push(join(root, 'Spine', 'Spine.com'), join(root, 'Esoteric Software', 'Spine', 'Spine.com'))
}
} else {
candidates.push('/opt/Spine/Spine.sh', join(homedir(), 'Spine/Spine.sh'), '/usr/local/bin/Spine', '/usr/bin/Spine')
}
for (const candidate of candidates) if (await executable(candidate)) return candidate
return null
}
export function normalizeSpineVersion(value) {
const match = /^(3\.8|4\.[0-3])(?:\.\d+)?$/.exec(String(value || '').trim())
if (!match || !SUPPORTED_VERSIONS.has(match[1])) throw new Error(`不支持的 Spine 版本:${value}`)
return match[1]
}
function run(executablePath, args, timeoutMs = 180000) {
return new Promise((resolve, reject) => {
execFile(executablePath, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
const details = [stdout, stderr, error.message]
.filter(Boolean)
.join('\n')
.replace(/^Licensed to:.*$/gim, 'Spine 授权信息已隐藏')
.trim()
reject(new Error(details || 'Spine 命令执行失败'))
return
}
resolve({ stdout, stderr })
})
})
}
export async function importSpineProject({ executablePath, inputPath, outputPath, projectName, spineVersion }) {
const version = normalizeSpineVersion(spineVersion)
const editorVersion = `${version}.xx`
const result = await run(executablePath, [
'--update', editorVersion,
'--input', inputPath,
'--output', outputPath,
'--import', projectName,
])
const output = await stat(outputPath).catch(() => null)
if (!output?.isFile() || output.size === 0) throw new Error('Spine 未生成有效的工程文件')
const detected = /Starting:\s+Spine\s+([0-9.]+)/i.exec(`${result.stdout}\n${result.stderr}`)?.[1] || editorVersion
return { ...result, editorVersion: detected, size: output.size }
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { findSpineExecutable, importSpineProject, normalizeSpineVersion } from './spine-cli.mjs'
import { allowOrigin, originAllowed, validWebOrigin } from './connector-config.mjs'
import { createConversionWorkspace, safeProjectName } from './spine-workspace.mjs'
import { createZipArchive } from './zip-archive.mjs'
const VERSION = '1.0.0'
const HOST = process.env.SPINE_CONNECTOR_HOST || '127.0.0.1'
const PORT = Math.max(1024, Math.min(65535, Number(process.env.SPINE_CONNECTOR_PORT) || 27843))
const MAX_BODY = 200 * 1024 * 1024
const pairing = new Map()
function cors(response, origin) {
if (origin) response.setHeader('Access-Control-Allow-Origin', origin)
response.setHeader('Vary', 'Origin')
response.setHeader('Access-Control-Allow-Private-Network', 'true')
response.setHeader('Access-Control-Allow-Headers', 'Content-Type')
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
}
function json(response, status, body, origin) {
cors(response, origin)
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify(body))
}
function html(response, status, body) {
response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
response.end(`<!doctype html><meta charset="utf-8"><title>Spine粒子连接器</title><style>body{font:16px system-ui;max-width:620px;margin:60px auto;padding:24px;color:#e9ebff;background:#10131e}main{padding:28px;border:1px solid #38415d;border-radius:12px;background:#181e2c}button{padding:10px 18px;border:0;border-radius:7px;color:white;background:#5b5ce2;cursor:pointer}code{word-break:break-all;color:#aeb8e8}</style><main>${body}</main>`)
}
async function readJson(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > MAX_BODY) throw new Error('请求数据不能超过 200MB')
chunks.push(chunk)
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
}
const server = createServer(async (request, response) => {
const origin = request.headers.origin || ''
const url = new URL(request.url || '/', `http://${HOST}:${PORT}`)
if (request.method === 'OPTIONS') { cors(response, origin); response.writeHead(204); response.end(); return }
try {
if (request.method === 'GET' && url.pathname === '/v1/health') {
const spinePath = await findSpineExecutable()
const authorized = await originAllowed(origin)
json(response, 200, {
connectorVersion: VERSION,
authorized,
spineFound: !!spinePath,
platform: process.platform,
supportedVersions: ['3.8', '4.0', '4.1', '4.2', '4.3'],
}, origin)
return
}
if (request.method === 'GET' && url.pathname === '/pair') {
const requestedOrigin = validWebOrigin(url.searchParams.get('origin'))
if (!requestedOrigin) { html(response, 400, '<h2>授权地址无效</h2>'); return }
const nonce = randomBytes(24).toString('hex')
pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 })
html(response, 200, `<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>${escapeHtml(requestedOrigin)}</code></p><form method="post" action="/pair"><input type="hidden" name="nonce" value="${nonce}"><button type="submit">允许此网站</button></form>`)
return
}
if (request.method === 'POST' && url.pathname === '/pair') {
const chunks = []
for await (const chunk of request) chunks.push(chunk)
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
const nonce = form.get('nonce') || ''
const pending = pairing.get(nonce)
pairing.delete(nonce)
if (!pending || pending.expires < Date.now()) { html(response, 400, '<h2>授权已过期,请返回编辑器重试</h2>'); return }
await allowOrigin(pending.origin)
html(response, 200, `<h2>授权成功</h2><p><code>${escapeHtml(pending.origin)}</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>`)
return
}
if (request.method === 'POST' && url.pathname === '/v1/convert') {
if (!(await originAllowed(origin))) {
const pairUrl = `http://${HOST}:${PORT}/pair?origin=${encodeURIComponent(origin)}`
json(response, 403, { code: 'ORIGIN_NOT_ALLOWED', message: '需要先授权当前网站使用本地 Spine 连接器', pairUrl }, origin)
return
}
const spinePath = await findSpineExecutable()
if (!spinePath) {
json(response, 503, { code: 'SPINE_NOT_FOUND', message: '没有找到 Spine,请先安装 Spine 或设置 SPINE_EXECUTABLE' }, origin)
return
}
const payload = await readJson(request)
const workspace = await createConversionWorkspace(payload)
try {
const spineVersion = normalizeSpineVersion(workspace.spineVersion)
const result = await importSpineProject({
executablePath: spinePath,
inputPath: workspace.inputPath,
outputPath: workspace.outputPath,
projectName: workspace.projectName,
spineVersion,
})
const project = await workspace.readOutput()
const projectFileName = `${safeProjectName(workspace.projectName)}.spine`
const archive = createZipArchive([
{ name: projectFileName, data: project },
...workspace.archiveImages,
])
const fileName = `${safeProjectName(workspace.projectName)}.zip`
const asciiFileName = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
cors(response, origin)
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Length': archive.length,
'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
'X-Spine-File-Name': encodeURIComponent(fileName),
'X-Spine-Editor-Version': result.editorVersion,
'Access-Control-Expose-Headers': 'X-Spine-File-Name, X-Spine-Editor-Version',
})
response.end(archive)
} finally {
await workspace.cleanup()
}
return
}
json(response, 404, { code: 'NOT_FOUND', message: '接口不存在' }, origin)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
json(response, 500, { code: 'CONVERSION_FAILED', message }, origin)
}
})
server.listen(PORT, HOST, () => {
console.log(`Spine粒子连接器 ${VERSION}`)
console.log(`监听地址:http://${HOST}:${PORT}`)
console.log('按 Ctrl+C 停止连接器')
})
+79
View File
@@ -0,0 +1,79 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, normalize, sep } from 'node:path'
const MAX_JSON_BYTES = 64 * 1024 * 1024
const MAX_IMAGE_BYTES = 32 * 1024 * 1024
const MAX_TOTAL_IMAGE_BYTES = 128 * 1024 * 1024
export function safeProjectName(value) {
const name = String(value || 'SpineParticle').trim().replace(/[\\/:*?"<>|\x00-\x1f]+/g, '_').replace(/^\.+/, '')
return name.slice(0, 100) || 'SpineParticle'
}
function safeImagePath(value) {
const raw = String(value || '').replace(/\\/g, '/')
if (!raw || raw.startsWith('/') || raw.includes('\0')) throw new Error('图片路径无效')
const path = normalize(raw)
if (path === '..' || path.startsWith(`..${sep}`)) throw new Error('图片路径不能超出 images 目录')
return path
}
function safeImagesDirectory(value) {
const raw = String(value || './images/').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '') || 'images'
if (raw.startsWith('/') || /^[a-z]+:/i.test(raw)) throw new Error('Spine 图片路径必须是工程内的相对路径')
return safeImagePath(raw)
}
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('图片数据格式无效')
const data = Buffer.from(value, 'base64')
if (data.length > MAX_IMAGE_BYTES) throw new Error('单张图片不能超过 32MB')
return data
}
export async function createConversionWorkspace(payload) {
if (!payload || payload.schemaVersion !== 1) throw new Error('不支持的连接器请求格式')
if (typeof payload.skeletonJson !== 'string' || Buffer.byteLength(payload.skeletonJson) > MAX_JSON_BYTES) throw new Error('Spine JSON 缺失或超过 64MB')
let skeleton
try { skeleton = JSON.parse(payload.skeletonJson) } catch { throw new Error('Spine JSON 格式无效') }
const jsonVersion = String(skeleton?.skeleton?.spine || '')
const spineVersion = /^(3\.8|4\.[0-3])(?:\.\d+)?$/.exec(jsonVersion)?.[1]
if (!spineVersion) throw new Error(`JSON 中的 Spine 版本不受支持:${jsonVersion || '未知'}`)
const requestedVersion = String(payload.spineVersion || spineVersion)
if (requestedVersion !== spineVersion) throw new Error(`请求版本 ${requestedVersion} 与 JSON 版本 ${jsonVersion} 不一致`)
const imagesDirectory = safeImagesDirectory(skeleton?.skeleton?.images)
const root = await mkdtemp(join(tmpdir(), 'spine-particle-'))
try {
const projectName = safeProjectName(payload.projectName)
const inputPath = join(root, `${projectName}.json`)
const outputPath = join(root, `${projectName}.spine`)
await writeFile(inputPath, payload.skeletonJson, 'utf8')
let total = 0
const archiveImages = []
for (const image of Array.isArray(payload.images) ? payload.images : []) {
const data = decodeBase64(image?.dataBase64)
total += data.length
if (total > MAX_TOTAL_IMAGE_BYTES) throw new Error('图片总大小不能超过 128MB')
const relativeImagePath = safeImagePath(image?.path)
const target = join(root, imagesDirectory, relativeImagePath)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, data)
archiveImages.push({ name: `${imagesDirectory.replace(/\\/g, '/')}/${relativeImagePath.replace(/\\/g, '/')}`, data })
}
return {
root,
projectName,
spineVersion,
archiveImages,
inputPath,
outputPath,
readOutput: () => readFile(outputPath),
cleanup: () => rm(root, { recursive: true, force: true }),
}
} catch (error) {
await rm(root, { recursive: true, force: true })
throw error
}
}
+78
View File
@@ -0,0 +1,78 @@
import { normalize } from 'node:path'
const CRC_TABLE = Array.from({ length: 256 }, (_, value) => {
let crc = value
for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1
return crc >>> 0
})
function crc32(data) {
let crc = 0xffffffff
for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
return (crc ^ 0xffffffff) >>> 0
}
function archivePath(value) {
const path = normalize(String(value || '').replace(/\\/g, '/')).replace(/\\/g, '/').replace(/^\.\//, '')
if (!path || path.startsWith('/') || path === '..' || path.startsWith('../')) throw new Error('ZIP 文件路径无效')
return path
}
function dosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear())
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
}
}
/** 创建无额外依赖的 ZIPStore 模式);PNG 和 .spine 本身已压缩,无需重复压缩。 */
export function createZipArchive(files) {
const localParts = []
const centralParts = []
let offset = 0
const timestamp = dosDateTime()
for (const file of files) {
const name = Buffer.from(archivePath(file.name), 'utf8')
const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data)
const crc = crc32(data)
const local = Buffer.alloc(30)
local.writeUInt32LE(0x04034b50, 0)
local.writeUInt16LE(20, 4)
local.writeUInt16LE(0x0800, 6)
local.writeUInt16LE(0, 8)
local.writeUInt16LE(timestamp.time, 10)
local.writeUInt16LE(timestamp.date, 12)
local.writeUInt32LE(crc, 14)
local.writeUInt32LE(data.length, 18)
local.writeUInt32LE(data.length, 22)
local.writeUInt16LE(name.length, 26)
localParts.push(local, name, data)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50, 0)
central.writeUInt16LE(20, 4)
central.writeUInt16LE(20, 6)
central.writeUInt16LE(0x0800, 8)
central.writeUInt16LE(0, 10)
central.writeUInt16LE(timestamp.time, 12)
central.writeUInt16LE(timestamp.date, 14)
central.writeUInt32LE(crc, 16)
central.writeUInt32LE(data.length, 20)
central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(name.length, 28)
central.writeUInt32LE(offset, 42)
centralParts.push(central, name)
offset += local.length + name.length + data.length
}
const centralDirectory = Buffer.concat(centralParts)
const end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50, 0)
end.writeUInt16LE(files.length, 8)
end.writeUInt16LE(files.length, 10)
end.writeUInt32LE(centralDirectory.length, 12)
end.writeUInt32LE(offset, 16)
return Buffer.concat([...localParts, centralDirectory, end])
}
+8
View File
@@ -3,8 +3,16 @@
"name": "单个烟花",
"file": "单个烟花.json"
},
{
"name": "雷电",
"file": "雷电.json"
},
{
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
},
{
"name": "烟花金币spine",
"file": "烟花金币spine.json"
}
]
+2134
View File
File diff suppressed because one or more lines are too long
+3313
View File
File diff suppressed because one or more lines are too long
+1239
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1239
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-CPFl-YCv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-n3FHecj5.css">
<script type="module" crossorigin src="/assets/index-C87f76bX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CJwt9TyR.css">
</head>
<body>
<div id="app"></div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
View File
Binary file not shown.
+5
View File
@@ -6,9 +6,14 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"connector": "node connector/spine-connector.mjs",
"build:connector": "node scripts/build-connector-sea.mjs",
"test:loop-seam": "node scripts/verify-loop-seam.mjs",
"test:timeline": "node scripts/verify-timeline.mjs",
"test:spine-json": "node scripts/verify-spine-json.mjs",
"test:connector": "node scripts/verify-spine-connector.mjs",
"test:connector:sea": "node scripts/verify-connector-sea.mjs",
"test:connector-versions": "node scripts/verify-spine-connector-versions.mjs",
"preview": "vite preview"
},
"dependencies": {
+4
View File
@@ -3,6 +3,10 @@
"name": "单个烟花",
"file": "单个烟花.json"
},
{
"name": "雷电",
"file": "雷电.json"
},
{
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.
+152
View File
@@ -0,0 +1,152 @@
import { execFile } from 'node:child_process'
import { access, chmod, cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { build } from 'esbuild'
const run = promisify(execFile)
const scriptDir = dirname(fileURLToPath(import.meta.url))
const projectDir = resolve(scriptDir, '..')
const releaseRoot = join(projectDir, 'release', 'connector')
const platformName = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform
const packageDir = join(releaseRoot, `${platformName}-${process.arch}`)
const executableName = process.platform === 'win32' ? 'SpineParticleConnector.exe' : 'SpineParticleConnector'
const executablePath = join(packageDir, executableName)
const launcherSource = join(projectDir, 'connector', 'launchers', platformName)
const nodeTarget = `node${process.versions.node.split('.')[0]}`
function seaUnavailable(error) {
return /single executable application is disabled/i.test(String(error?.stderr || '') + String(error?.stdout || '') + String(error))
}
async function downloadOfficialSeaNode(stagingDir) {
const version = process.versions.node
const architecture = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null
if (!architecture || !['darwin', 'win32'].includes(process.platform)) {
throw new Error('当前系统的 Node 不支持 SEA。请安装 Node 官方发行版,或通过 SEA_NODE_BINARY 指定可用的 Node 可执行文件。')
}
const osName = process.platform === 'darwin' ? 'darwin' : 'win'
const extension = process.platform === 'darwin' ? 'tar.gz' : 'zip'
const archiveName = `node-v${version}-${osName}-${architecture}.${extension}`
const archivePath = join(stagingDir, archiveName)
const url = `https://nodejs.org/dist/v${version}/${archiveName}`
console.log(`当前 Node 禁用了 SEA,正在临时使用 Node 官方构建器:${url}`)
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) })
if (!response.ok) throw new Error(`无法下载 Node 官方构建器(${response.status}):${url}`)
await writeFile(archivePath, Buffer.from(await response.arrayBuffer()))
if (process.platform === 'darwin') await run('tar', ['-xzf', archivePath, '-C', stagingDir])
else await run('powershell.exe', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${stagingDir.replace(/'/g, "''")}' -Force`])
const binary = process.platform === 'darwin'
? join(stagingDir, `node-v${version}-${osName}-${architecture}`, 'bin', 'node')
: join(stagingDir, `node-v${version}-${osName}-${architecture}`, 'node.exe')
await access(binary, constants.X_OK)
return binary
}
async function buildSea(configPath, stagingDir) {
const configuredBinary = process.env.SEA_NODE_BINARY
const candidates = configuredBinary ? [configuredBinary, process.execPath] : [process.execPath]
let lastError
for (const binary of candidates) {
try {
await run(binary, ['--build-sea', configPath])
return
} catch (error) {
lastError = error
if (!seaUnavailable(error)) throw error
}
}
const officialBinary = await downloadOfficialSeaNode(stagingDir)
try {
await run(officialBinary, ['--build-sea', configPath])
} catch (error) {
throw new Error(`Node 官方构建器无法生成 SEA${error instanceof Error ? error.message : error}`, { cause: lastError })
}
}
async function createPortableArchive() {
const archivePath = join(releaseRoot, `SpineParticleConnector-${platformName}-${process.arch}.zip`)
await rm(archivePath, { force: true })
if (process.platform === 'darwin') {
await run('zip', ['-q', '-r', archivePath, basename(packageDir)], { cwd: releaseRoot })
} else if (process.platform === 'win32') {
const escapedSource = packageDir.replace(/'/g, "''")
const escapedTarget = archivePath.replace(/'/g, "''")
await run('powershell.exe', ['-NoProfile', '-Command', `Compress-Archive -LiteralPath '${escapedSource}' -DestinationPath '${escapedTarget}' -Force`])
} else {
return null
}
return archivePath
}
async function main() {
const stagingDir = await mkdtemp(join(tmpdir(), 'spine-particle-sea-'))
try {
const bundlePath = join(stagingDir, 'spine-connector.cjs')
const configPath = join(stagingDir, 'sea-config.json')
await build({
entryPoints: [join(projectDir, 'connector', 'spine-connector.mjs')],
outfile: bundlePath,
bundle: true,
platform: 'node',
format: 'cjs',
target: nodeTarget,
legalComments: 'none',
})
// The release directory is generated output. Recreate this exact package folder
// so a rebuild cannot retain files from an older connector version.
await rm(packageDir, { recursive: true, force: true })
await mkdir(packageDir, { recursive: true })
await writeFile(configPath, `${JSON.stringify({
main: bundlePath,
mainFormat: 'commonjs',
output: executablePath,
disableExperimentalSEAWarning: true,
useCodeCache: false,
}, null, 2)}\n`, 'utf8')
await buildSea(configPath, stagingDir)
if (process.platform !== 'win32') await chmod(executablePath, 0o755)
if (process.platform === 'darwin') {
// SEA modifies a copy of Node. Re-sign it locally so macOS will execute it.
await run('codesign', ['--force', '--sign', '-', '--timestamp=none', executablePath])
}
await cp(launcherSource, packageDir, { recursive: true, force: true })
if (process.platform !== 'win32') {
await Promise.all([
chmod(join(packageDir, '启动Spine连接器.command'), 0o755),
chmod(join(packageDir, '停止Spine连接器.command'), 0o755),
])
}
await cp(join(projectDir, 'connector', 'DISTRIBUTION.md'), join(packageDir, '使用说明.md'), { force: true })
await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({
name: 'SpineParticleConnector',
connectorVersion: '1.0.0',
platform: process.platform,
architecture: process.arch,
executable: basename(executablePath),
builtWithNode: process.version,
}, null, 2)}\n`, 'utf8')
const archivePath = await createPortableArchive()
console.log(`已生成独立连接器:${packageDir}`)
console.log(`可执行文件:${executablePath}`)
if (archivePath) console.log(`分发压缩包:${archivePath}`)
} finally {
await rm(stagingDir, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(`构建独立连接器失败:${error instanceof Error ? error.message : error}`)
process.exitCode = 1
})
+59
View File
@@ -0,0 +1,59 @@
import { access, mkdtemp, rm } from 'node:fs/promises'
import { constants } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
const projectDir = resolve(fileURLToPath(new URL('..', import.meta.url)))
const platformName = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform
const executableName = process.platform === 'win32' ? 'SpineParticleConnector.exe' : 'SpineParticleConnector'
const executablePath = join(projectDir, 'release', 'connector', `${platformName}-${process.arch}`, executableName)
const port = 27844
async function waitForHealth() {
const deadline = Date.now() + 10_000
let lastError = null
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/v1/health`)
if (response.ok) return response.json()
lastError = new Error(`健康检查返回 ${response.status}`)
} catch (error) {
lastError = error
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150))
}
throw lastError || new Error('健康检查超时')
}
async function main() {
await access(executablePath, constants.X_OK)
const configDir = await mkdtemp(join(tmpdir(), 'spine-particle-sea-test-'))
const processHandle = spawn(executablePath, [], {
env: { ...process.env, SPINE_CONNECTOR_PORT: String(port), SPINE_CONNECTOR_CONFIG: join(configDir, 'config.json') },
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
processHandle.stdout.on('data', (chunk) => { output += chunk })
processHandle.stderr.on('data', (chunk) => { output += chunk })
try {
const health = await waitForHealth()
if (health.connectorVersion !== '1.0.0' || !Array.isArray(health.supportedVersions) || !health.supportedVersions.includes('4.3')) {
throw new Error(`独立连接器健康检查内容不正确:${JSON.stringify(health)}`)
}
console.log(`独立连接器验证通过:${executablePath}`)
console.log(`支持版本:${health.supportedVersions.join(', ')}`)
} finally {
if (!processHandle.killed) processHandle.kill('SIGTERM')
await new Promise((resolveDelay) => processHandle.once('exit', resolveDelay)).catch(() => {})
if (!processHandle.killed) processHandle.kill('SIGKILL')
await rm(configDir, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+35
View File
@@ -0,0 +1,35 @@
import { build } from 'esbuild'
const bundled = await build({
bundle: true,
entryPoints: ['src/core/loopFrameBaker.ts'],
format: 'esm',
platform: 'node',
treeShaking: true,
write: false,
})
const source = bundled.outputFiles[0].text
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
const { closeLoopFrameSeam } = await import(moduleUrl)
const first = [{
boneName: 'p_0', spawnId: 7, x: 12, y: -4, rotation: 30,
scaleX: 1.2, scaleY: 0.8, alpha: 0.75, colorHex: 0xffcc00,
resourceId: 1, imageFrameIndex: 2, imageVisible: true, active: true,
trailState: {
bones: [{ boneName: 'trail_0', x: 1, y: 2, rotation: 3 }],
activeBoneCount: 1, width: 5, alpha: 0.5, colorHex: 0xffffff, resourceId: 1,
},
}]
const frames = [first, [{ ...first[0], spawnId: 99, x: 200, y: 300 }]]
closeLoopFrameSeam(frames)
if (JSON.stringify(frames[0]) !== JSON.stringify(frames[1])) {
throw new Error('循环尾帧没有与首帧保持完全一致')
}
if (frames[0] === frames[1] || frames[0][0] === frames[1][0] || frames[0][0].trailState === frames[1][0].trailState) {
throw new Error('循环闭合帧必须是深拷贝,不能共享可变状态')
}
console.log('循环持续:首尾粒子身份、变换、生命周期表现及拖尾状态完全一致')
@@ -0,0 +1,31 @@
const connectorUrl = process.env.SPINE_CONNECTOR_URL || 'http://127.0.0.1:27843'
const origin = process.env.SPINE_CONNECTOR_TEST_ORIGIN || 'http://127.0.0.1:5173'
const versions = ['3.8', '4.0', '4.1', '4.2', '4.3']
const results = []
for (const version of versions) {
const skeleton = {
skeleton: { spine: version, images: './images/', fps: 30 },
bones: [{ name: 'root' }],
slots: [],
skins: version === '3.8' ? { default: {} } : [{ name: 'default', attachments: {} }],
animations: { animation: {} },
}
const response = await fetch(`${connectorUrl}/v1/convert`, {
method: 'POST',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({
schemaVersion: 1,
projectName: `ConnectorVersion${version.replace('.', '')}`,
spineVersion: version,
skeletonJson: JSON.stringify(skeleton),
images: [],
}),
})
if (!response.ok) throw new Error(`Spine ${version} 转换失败:${await response.text()}`)
const bytes = (await response.arrayBuffer()).byteLength
if (!bytes) throw new Error(`Spine ${version} 返回了空工程`)
results.push({ requested: version, editor: response.headers.get('x-spine-editor-version'), bytes })
}
console.log(JSON.stringify(results, null, 2))
+68
View File
@@ -0,0 +1,68 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const root = new URL('../', import.meta.url)
const connectorUrl = process.env.SPINE_CONNECTOR_URL || 'http://127.0.0.1:27843'
const origin = process.env.SPINE_CONNECTOR_TEST_ORIGIN || 'http://127.0.0.1:5173'
const imagePath = new URL('public/images/star.png', root)
const healthResponse = await fetch(`${connectorUrl}/v1/health`, { headers: { Origin: origin } })
if (!healthResponse.ok) throw new Error(`连接器健康检查失败:HTTP ${healthResponse.status}`)
const health = await healthResponse.json()
if (!health.authorized) throw new Error('测试来源没有获得连接器授权')
if (!health.spineFound) throw new Error('连接器没有找到本机 Spine')
const skeletonJson = JSON.stringify({
skeleton: { spine: '4.2', images: './images/', fps: 30 },
bones: [{ name: 'root' }, { name: 'particle', parent: 'root' }],
slots: [{ name: 'particle', bone: 'particle', attachment: 'star' }],
skins: [{ name: 'default', attachments: { particle: { star: { path: 'star', width: 64, height: 64 } } } }],
animations: { animation: {} },
})
const image = await readFile(imagePath)
const payload = {
schemaVersion: 1,
projectName: 'Spine粒子连接器测试',
spineVersion: JSON.parse(skeletonJson).skeleton.spine,
skeletonJson,
images: [{ path: 'star.png', dataBase64: image.toString('base64') }],
}
const response = await fetch(`${connectorUrl}/v1/convert`, {
method: 'POST',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error(`转换失败:${await response.text()}`)
const temp = await mkdtemp(join(tmpdir(), 'spine-connector-verify-'))
const output = join(temp, 'SpineParticleConnectorTest.zip')
try {
const archive = Buffer.from(await response.arrayBuffer())
await writeFile(output, archive)
const info = await stat(output)
if (!info.isFile() || info.size === 0 || archive.readUInt32LE(0) !== 0x04034b50) throw new Error('连接器返回的不是有效 ZIP')
const entries = []
let offset = 0
while (offset + 30 <= archive.length && archive.readUInt32LE(offset) === 0x04034b50) {
const size = archive.readUInt32LE(offset + 18)
const nameLength = archive.readUInt16LE(offset + 26)
const extraLength = archive.readUInt16LE(offset + 28)
const name = archive.subarray(offset + 30, offset + 30 + nameLength).toString('utf8')
entries.push({ name, size })
offset += 30 + nameLength + extraLength + size
}
if (!entries.some((entry) => entry.name.endsWith('.spine') && entry.size > 0)) throw new Error('ZIP 中缺少 .spine 工程')
if (!entries.some((entry) => entry.name === 'images/star.png' && entry.size === image.length)) throw new Error('ZIP 中缺少 images/star.png')
console.log(JSON.stringify({
connectorVersion: health.connectorVersion,
requestedSpineVersion: payload.spineVersion,
actualSpineVersion: response.headers.get('x-spine-editor-version'),
encodedFileName: response.headers.get('x-spine-file-name'),
imageCount: payload.images.length,
archiveBytes: info.size,
entries,
}, null, 2))
} finally {
await rm(temp, { recursive: true, force: true })
}
+55
View File
@@ -0,0 +1,55 @@
import { build } from 'esbuild'
const source = `
import { buildSpineJson } from './src/export/spineJsonExporter.ts'
import { validateSpineJson } from './src/export/spineJsonValidator.ts'
const resource = {
id: 1, imageMode: 'single', sequenceFrames: [], textureName: 'star.png', textureFolder: '',
anchorX: 0.5, anchorY: 0.5, blend: 'normal', independentAlpha: 'off',
}
const frames = Array.from({ length: 31 }, (_, frame) => [{
active: true, boneName: 'particle', spawnId: 1, resourceId: 1, imageFrameIndex: 0,
imageVisible: true, colorHex: 0xffffff, alpha: 1,
x: frame, y: frame / 2, rotation: frame, scaleX: 1, scaleY: 1,
worldX: frame, worldY: frame / 2, worldRotation: frame, worldScaleX: 1, worldScaleY: 1,
}])
const system = {
id: 1, animation: 'animation', visible: true, frames,
config: {
name: 'verify', mode: 'burst', lifeMode: 'fixed', lifeMin: 1, lifeMax: 1, delay: 0,
imageResources: [resource], trail: false, trailResources: [],
},
}
const timeline = { fps: 30, animations: ['animation'] }
let failures = 0
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) {
const settings = {
imagesPath: './images/', spineVersion: version, keyframeCurve: 'linear', fps: 30,
omitFps: false, integerFrameAlignment: true, bonePool: true,
excludeEmptyAnimations: false, fileName: 'verify.json',
}
const result = buildSpineJson({ systems: [system], timeline, settings })
try {
const validation = validateSpineJson(result.text, version)
console.log('Spine ' + version + ':', validation.bones + ' bones,', validation.slots + ' slots,', validation.animations + ' animations')
} catch (error) {
failures++
console.error('Spine ' + version + ': ' + (error instanceof Error ? error.message : String(error)))
}
}
if (failures) process.exitCode = 1
`
const result = await build({
stdin: { contents: source, resolveDir: process.cwd(), sourcefile: 'verify-spine-json.ts', loader: 'ts' },
bundle: true,
platform: 'node',
format: 'esm',
write: false,
logLevel: 'silent',
})
const code = Buffer.from(result.outputFiles[0].contents).toString('base64')
await import(`data:text/javascript;base64,${code}`)
+92
View File
@@ -0,0 +1,92 @@
import { build } from 'esbuild'
const bundled = await build({
bundle: true,
stdin: {
contents: [
"export { bakeLoopFrames, closeLoopFrameSeam } from './src/core/loopFrameBaker.ts'",
"export { loopPlaybackRange, loopSegmentTiming } from './src/core/loopSegmentTiming.ts'",
"export { normalizePlaybackRange, resizePlaybackRange } from './src/timeline/playbackRange.ts'",
].join('\n'),
resolveDir: process.cwd(),
sourcefile: 'timeline-test-entry.ts',
},
format: 'esm',
platform: 'node',
write: false,
})
const source = bundled.outputFiles[0].text
const api = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
const timing = api.loopSegmentTiming({
loopDurationFrames: 30,
generateLoopStartAnimation: true, loopStartUseCustomDuration: true,
loopStartDurationFrames: 20,
generateLoopEndAnimation: true, loopEndUseCustomDuration: true,
loopEndDurationFrames: 40,
})
if (JSON.stringify(timing) !== JSON.stringify({ start: 20, loop: 30, end: 40, total: 90 })) {
throw new Error('开始、循环、结束三段时长计算错误')
}
const automaticTiming = api.loopSegmentTiming({
lifeMode: 'random', lifeMin: 0.5, lifeMax: 1.5,
loopDurationFrames: 30,
generateLoopStartAnimation: true, loopStartUseCustomDuration: false, loopStartDurationFrames: 20,
generateLoopEndAnimation: true, loopEndUseCustomDuration: false, loopEndDurationFrames: 40,
}, 30)
if (automaticTiming.start !== 45 || automaticTiming.end !== 45) {
throw new Error('未指定时长时应按最大粒子生命周期自动计算')
}
const loopRange = api.loopPlaybackRange({
delay: 0, loopDurationFrames: 80,
generateLoopStartAnimation: true, loopStartUseCustomDuration: true, loopStartDurationFrames: 20,
generateLoopEndAnimation: true, loopEndUseCustomDuration: true, loopEndDurationFrames: 20,
}, 30)
if (loopRange.start !== 20 || loopRange.end !== 100) {
throw new Error('时间轴上的闭合自循环范围计算错误')
}
const normalized = api.normalizePlaybackRange(10, 10, 100)
if (normalized.start !== 10 || normalized.end !== 11) throw new Error('播放范围必须至少包含两个帧位置')
const resized = api.resizePlaybackRange({ start: 5, end: 59 }, 60, 90)
if (resized.start !== 5 || resized.end !== 89) throw new Error('全范围播放没有随总时长扩展')
function state(x) {
return [{
boneName: 'p_0', spawnId: 3, x, y: 0, rotation: 0,
scaleX: 1, scaleY: 1, alpha: 1, colorHex: 0xffffff,
resourceId: 1, imageFrameIndex: 0, imageVisible: true, active: true,
}]
}
const emitter = {
step: 0,
activeCount: 1,
reset() { this.step = 0; this.activeCount = 1 },
restartEmissionCycle() { this.step = 0 },
update(_dt, mode) { if (mode === 'off') this.activeCount = 0; else this.step++; return this.capture() },
capture() { return this.activeCount ? state(this.step) : [] },
apply() {},
}
const system = {
config: {
lifeMode: 'fixed', lifeMin: 1, lifeMax: 1, delay: 0,
loopDurationFrames: 3,
generateLoopStartAnimation: true, loopStartUseCustomDuration: true, loopStartDurationFrames: 2,
generateLoopEndAnimation: true, loopEndUseCustomDuration: true, loopEndDurationFrames: 4,
},
}
api.bakeLoopFrames(system, emitter, 3, 10)
const frames = system.frames
if (frames.length !== 10) throw new Error('组合动画总帧数错误')
if (JSON.stringify(frames[2]) !== JSON.stringify(frames[5])) throw new Error('启动末帧、循环首尾帧没有精确衔接')
if (frames[0].some((particle) => particle.imageVisible !== false || particle.alpha !== 0)) {
throw new Error('启动段首帧应把衔接帧粒子反推到未出生状态')
}
if (frames.slice(0, 2).some((frame) => frame.some((particle) => particle.x > frames[2][0].x))) {
throw new Error('启动段不应包含衔接帧之后的额外循环')
}
if (frames[9].length !== 0) throw new Error('结束段末帧应为空')
console.log('时间轴 A-B 范围与开始/循环/结束三段边界校验通过')
+161
View File
@@ -0,0 +1,161 @@
import type { ParticleSystem } from '../store/particleStore'
import type { SpineExportVersion } from './spineExportSettings'
const CONNECTOR_URL = 'http://127.0.0.1:27843'
export interface SpineConnectorHealth {
available: boolean
authorized: boolean
connectorVersion?: string
spineFound?: boolean
spinePath?: string
platform?: string
error?: string
}
export interface ConnectorImage {
path: string
dataBase64: string
}
interface ConnectorErrorBody {
code?: string
message?: string
pairUrl?: string
}
export class SpineConnectorError extends Error {
code: string
pairUrl?: string
constructor(message: string, code = 'CONNECTOR_ERROR', pairUrl?: string) {
super(message)
this.name = 'SpineConnectorError'
this.code = code
this.pairUrl = pairUrl
}
}
function cleanPart(value: string, fallback: string) {
const cleaned = String(value || '').trim().replace(/[\\/:*?"<>|]+/g, '_').replace(/^\.+/, '')
return cleaned || fallback
}
function extensionFor(name: string, contentType: string) {
const match = /\.([a-z0-9]{2,5})$/i.exec(name)
if (match) return `.${match[1].toLowerCase()}`
if (contentType.includes('jpeg')) return '.jpg'
if (contentType.includes('webp')) return '.webp'
if (contentType.includes('gif')) return '.gif'
return '.png'
}
function imagePath(folder: string, name: string, contentType: string) {
const parts = String(folder || '').replace(/\\/g, '/').split('/').filter(Boolean).map((part) => cleanPart(part, 'images'))
const base = cleanPart(name.replace(/\.[^./\\]+$/, ''), 'image')
return [...parts, `${base}${extensionFor(name, contentType)}`].join('/')
}
async function sourceToImage(source: string, folder: string, name: string): Promise<ConnectorImage | null> {
if (!source) return null
const response = await fetch(source)
if (!response.ok) throw new Error(`无法读取导出图片:${name}`)
const blob = await response.blob()
const bytes = new Uint8Array(await blob.arrayBuffer())
let binary = ''
const chunkSize = 0x8000
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))
}
return {
path: imagePath(folder, name, blob.type),
dataBase64: btoa(binary),
}
}
/** 收集 Spine JSON 中附件会引用的粒子与拖尾图片。 */
export async function collectSpineExportImages(systems: ParticleSystem[]) {
const requests: Array<Promise<ConnectorImage | null>> = []
for (const system of systems.filter((item) => item.visible !== false)) {
for (const resource of system.config.imageResources) {
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
for (const frame of resource.sequenceFrames) {
requests.push(sourceToImage(frame.previewUrl, resource.textureFolder, frame.name || resource.textureName))
}
} else {
requests.push(sourceToImage(resource.previewUrl, resource.textureFolder, resource.textureName))
}
}
if (system.config.trail) {
for (const resource of system.config.trailResources) {
requests.push(sourceToImage(resource.previewUrl, '', resource.textureName))
}
}
}
const unique = new Map<string, ConnectorImage>()
for (const image of await Promise.all(requests)) if (image && !unique.has(image.path)) unique.set(image.path, image)
return [...unique.values()]
}
export async function checkSpineConnector(): Promise<SpineConnectorHealth> {
try {
const response = await fetch(`${CONNECTOR_URL}/v1/health`, { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = await response.json()
return { available: true, ...body }
} catch (error) {
return { available: false, authorized: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function convertWithSpineConnector(input: {
projectName: string
spineVersion: SpineExportVersion
skeletonJson: string
systems: ParticleSystem[]
}) {
const images = await collectSpineExportImages(input.systems)
let response: Response
try {
response = await fetch(`${CONNECTOR_URL}/v1/convert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schemaVersion: 1,
projectName: input.projectName,
spineVersion: input.spineVersion,
skeletonJson: input.skeletonJson,
images,
}),
})
} catch {
throw new SpineConnectorError('未检测到本地 Spine 连接器,请先启动连接器', 'CONNECTOR_OFFLINE')
}
if (!response.ok) {
const body = await response.json().catch(() => ({} as ConnectorErrorBody)) as ConnectorErrorBody
throw new SpineConnectorError(body.message || `Spine 连接器返回错误:HTTP ${response.status}`, body.code, body.pairUrl)
}
const encodedFileName = response.headers.get('X-Spine-File-Name')
let fileName = `${cleanPart(input.projectName, 'SpineParticle')}.zip`
if (encodedFileName) {
try { fileName = decodeURIComponent(encodedFileName) } catch { /* 保留安全的默认文件名 */ }
}
return {
blob: await response.blob(),
fileName,
spineEditorVersion: response.headers.get('X-Spine-Editor-Version') || input.spineVersion,
imageCount: images.length,
}
}
export function downloadSpineProject(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = /\.zip$/i.test(fileName) ? fileName : `${fileName}.zip`
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+47
View File
@@ -0,0 +1,47 @@
import type { ParticleSystem } from '../store/particleStore'
import { collectSpineExportImages } from './spineConnectorClient'
import { createZipArchive } from './zipArchive'
function decodeBase64(value: string) {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
return bytes
}
function imagesDirectory(value: string) {
const path = String(value || './images/').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
const parts = path.split('/').filter(Boolean)
if (!parts.length || parts.some((part) => part === '..' || /^[a-z]+:/i.test(part))) return 'images'
return parts.join('/')
}
function safeFileName(value: string) {
const name = String(value || 'SpineParticle').replace(/\.json$/i, '').replace(/[\\/:*?"<>|]+/g, '_').replace(/^\.+/, '')
return name || 'SpineParticle'
}
export async function buildSpineImagesArchive(input: { systems: ParticleSystem[]; imagesPath: string }) {
const images = await collectSpineExportImages(input.systems)
if (!images.length) throw new Error('当前可见粒子系统没有可导出的图片')
const folder = imagesDirectory(input.imagesPath)
const blob = createZipArchive(images.map((image) => ({
name: `${folder}/${image.path}`,
data: decodeBase64(image.dataBase64),
})))
return { blob, imageCount: images.length, folder }
}
export function downloadSpineImagesArchive(blob: Blob, exportFileName: string) {
const fileName = `${safeFileName(exportFileName)}-images.zip`
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
return fileName
}
+91
View File
@@ -0,0 +1,91 @@
export interface ZipArchiveFile {
name: string
data: Uint8Array
}
const crcTable = Array.from({ length: 256 }, (_, value) => {
let crc = value
for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1
return crc >>> 0
})
function crc32(data: Uint8Array) {
let crc = 0xffffffff
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
return (crc ^ 0xffffffff) >>> 0
}
function safeArchivePath(value: string) {
const parts = String(value || '').replace(/\\/g, '/').split('/').filter((part) => part && part !== '.')
if (!parts.length || parts.some((part) => part === '..')) throw new Error('ZIP 文件路径无效')
return parts.join('/')
}
function dateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear())
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
}
}
function header(size: number) {
const bytes = new Uint8Array(size)
return { bytes, view: new DataView(bytes.buffer) }
}
function combine(chunks: Uint8Array[]) {
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
let offset = 0
for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length }
return output
}
/** 创建 Store 模式 ZIP;图片本身已经压缩,避免引入额外打包依赖。 */
export function createZipArchive(files: ZipArchiveFile[]) {
const encoder = new TextEncoder()
const localChunks: Uint8Array[] = []
const centralChunks: Uint8Array[] = []
const timestamp = dateTime()
let offset = 0
for (const file of files) {
const name = encoder.encode(safeArchivePath(file.name))
const crc = crc32(file.data)
const local = header(30)
local.view.setUint32(0, 0x04034b50, true)
local.view.setUint16(4, 20, true)
local.view.setUint16(6, 0x0800, true)
local.view.setUint16(10, timestamp.time, true)
local.view.setUint16(12, timestamp.date, true)
local.view.setUint32(14, crc, true)
local.view.setUint32(18, file.data.length, true)
local.view.setUint32(22, file.data.length, true)
local.view.setUint16(26, name.length, true)
localChunks.push(local.bytes, name, file.data)
const central = header(46)
central.view.setUint32(0, 0x02014b50, true)
central.view.setUint16(4, 20, true)
central.view.setUint16(6, 20, true)
central.view.setUint16(8, 0x0800, true)
central.view.setUint16(12, timestamp.time, true)
central.view.setUint16(14, timestamp.date, true)
central.view.setUint32(16, crc, true)
central.view.setUint32(20, file.data.length, true)
central.view.setUint32(24, file.data.length, true)
central.view.setUint16(28, name.length, true)
central.view.setUint32(42, offset, true)
centralChunks.push(central.bytes, name)
offset += local.bytes.length + name.length + file.data.length
}
const centralDirectory = combine(centralChunks)
const end = header(22)
end.view.setUint32(0, 0x06054b50, true)
end.view.setUint16(8, files.length, true)
end.view.setUint16(10, files.length, true)
end.view.setUint32(12, centralDirectory.length, true)
end.view.setUint32(16, offset, true)
return new Blob([combine([...localChunks, centralDirectory, end.bytes])], { type: 'application/zip' })
}
+156 -5
View File
@@ -900,15 +900,32 @@
</template>
</div>
<div class="spine-export-actions">
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
{{ exportBusy ? '正在准备导出' : '导出Spine Json' }}
{{ exportBusy && exportTarget === 'json' ? '正在准备导出' : '导出Spine Json' }}
</button>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
<button type="button" class="export-spine-images" :disabled="exportBusy" title="单独下载当前粒子系统使用的图片 ZIP" @click="exportSpineImages">
{{ exportBusy && exportTarget === 'images' ? '正在整理图片' : '导出 images ' }}
</button>
<button type="button" class="export-spine-project" :disabled="exportBusy || !connectorReady" :title="connectorButtonTitle" @click="exportSpineProject">
<span>{{ exportBusy && exportTarget === 'spine' ? '正在生成工程…' : '导出 .spine 包' }}</span>
<small v-if="!exportBusy && connectorDisabledReason">{{ connectorDisabledReason }}</small>
</button>
</div>
<div class="connector-status" :class="connectorStatusClass">
<span class="connector-dot"></span>{{ connectorStatusText }}
<a v-if="connectorAuthorizationUrl" :href="connectorAuthorizationUrl" target="_blank" rel="noopener" title="在本机连接器中授权当前网站">授权当前网站</a>
<button type="button" title="重新检测连接器" @click="refreshConnectorHealth">刷新</button>
</div>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">
{{ exportMessage }}
<a v-if="connectorPairUrl" :href="connectorPairUrl" target="_blank" rel="noopener" @click="connectorPairUrl = ''">授权当前网站</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, markRaw, onMounted, reactive, ref, watchEffect } from 'vue'
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../store/particleStore'
import type { ParticleSystem } from '../store/particleStore'
@@ -923,6 +940,14 @@ import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
import { validateSpineJson } from '../export/spineJsonValidator'
import { buildSpineImagesArchive, downloadSpineImagesArchive } from '../export/spineImagesExporter'
import {
SpineConnectorError,
checkSpineConnector,
convertWithSpineConnector,
downloadSpineProject,
type SpineConnectorHealth,
} from '../export/spineConnectorClient'
import { automaticLoopTransitionFrames } from '../core/loopSegmentTiming'
const store = useParticleStore()
@@ -935,8 +960,43 @@ const presetLoading = ref(false)
const configMessage = ref('')
const configError = ref(false)
const exportBusy = ref(false)
const exportTarget = ref<'json' | 'spine' | 'images' | null>(null)
const exportMessage = ref('')
const exportError = ref(false)
const connectorHealth = ref<SpineConnectorHealth | null>(null)
const connectorPairUrl = ref('')
const connectorStatusText = computed(() => {
const health = connectorHealth.value
if (!health) return '正在检测本地 Spine 连接器…'
if (!health.available) return '本地 Spine 连接器未启动'
if (!health.spineFound) return '连接器已启动,但未找到 Spine'
if (!health.authorized) return '连接器已启动,当前网站尚未授权'
return '连接器可用 · Spine 已就绪'
})
const connectorStatusClass = computed(() => ({
ready: !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized,
warning: !!connectorHealth.value?.available && (!connectorHealth.value?.spineFound || !connectorHealth.value?.authorized),
}))
const connectorReady = computed(() => !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized)
const connectorAuthorizationUrl = computed(() => {
const health = connectorHealth.value
if (!health?.available || !health.spineFound || health.authorized || typeof window === 'undefined') return ''
return `http://127.0.0.1:27843/pair?origin=${encodeURIComponent(window.location.origin)}`
})
const connectorDisabledReason = computed(() => {
const health = connectorHealth.value
if (!health) return '检测中'
if (!health.available) return '未启动'
if (!health.spineFound) return '未找到 Spine'
if (!health.authorized) return '未授权'
return ''
})
const connectorButtonTitle = computed(() => {
if (exportBusy.value) return '正在重新计算并生成 Spine 工程'
if (connectorDisabledReason.value) return `暂不可用:${connectorDisabledReason.value}`
return `调用本机 Spine 自动生成 ${exportSettings.value.spineVersion} 工程`
})
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
@@ -1002,7 +1062,20 @@ async function loadSelectedPreset() {
}
}
onMounted(refreshPresets)
onMounted(() => {
refreshPresets()
refreshConnectorHealth()
connectorHealthTimer = window.setInterval(refreshConnectorHealth, 3000)
})
let connectorHealthTimer: number | undefined
onBeforeUnmount(() => {
if (connectorHealthTimer !== undefined) window.clearInterval(connectorHealthTimer)
})
async function refreshConnectorHealth() {
connectorHealth.value = await checkSpineConnector()
}
function nextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
@@ -1027,7 +1100,9 @@ async function prepareExportFrames() {
async function exportSpineJson() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'json'
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在重新计算粒子动画…'
try {
await prepareExportFrames()
@@ -1041,6 +1116,63 @@ async function exportSpineJson() {
exportMessage.value = error instanceof Error ? error.message : String(error)
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
async function exportSpineProject() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'spine'
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在重新计算粒子动画…'
try {
await prepareExportFrames()
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
validateSpineJson(result.text, exportSettings.value.spineVersion)
exportMessage.value = '正在整理图片并调用本机 Spine…'
const projectName = exportSettings.value.fileName.replace(/\.json$/i, '') || 'SpineParticle'
const converted = await convertWithSpineConnector({
projectName,
spineVersion: exportSettings.value.spineVersion,
skeletonJson: result.text,
systems: store.systems as unknown as ParticleSystem[],
})
downloadSpineProject(converted.blob, converted.fileName)
exportMessage.value = `已生成 ${converted.fileName}:包含 .spine 工程和 ${converted.imageCount} 张图片,Spine ${converted.spineEditorVersion}`
await refreshConnectorHealth()
} catch (error) {
exportError.value = true
if (error instanceof SpineConnectorError && error.pairUrl) connectorPairUrl.value = error.pairUrl
exportMessage.value = error instanceof Error ? error.message : String(error)
await refreshConnectorHealth()
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
async function exportSpineImages() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'images'
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在整理当前粒子系统使用的图片…'
try {
const archive = await buildSpineImagesArchive({
systems: store.systems as unknown as ParticleSystem[],
imagesPath: exportSettings.value.imagesPath,
})
const fileName = downloadSpineImagesArchive(archive.blob, exportSettings.value.fileName)
exportMessage.value = `已生成 ${fileName}${archive.folder}/ 内包含 ${archive.imageCount} 张图片`
} catch (error) {
exportError.value = true
exportMessage.value = error instanceof Error ? error.message : String(error)
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
@@ -1960,11 +2092,30 @@ function toggleResourceLock(resourceId: number) {
.export-switches { display: grid; gap: 13px; margin: 4px 0 7px; padding-top: 2px; }
.export-switch { font-weight: 600; }
.export-switch-last { margin-top: 13px; }
.export-spine-json { width: 100%; margin-top: 10px; padding: 9px 12px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.spine-export-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 10px; }
.export-spine-json { width: 100%; padding: 9px 8px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-json:hover { border-color: #7774ff; background: #514bc0; }
.export-spine-json:disabled { cursor: wait; opacity: .68; }
.export-spine-project { grid-column: 1 / -1; width: 100%; padding: 7px 8px; border: 1px solid #287d69; border-radius: 7px; background: #196854; color: #edfff9; font-size: 12px; font-weight: 700; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.export-spine-project small { color: inherit; font-size: 9px; font-weight: 500; line-height: 1.1; }
.export-spine-project:hover:not(:disabled) { border-color: #47c89f; background: #238267; }
.export-spine-project:disabled { cursor: not-allowed; opacity: .48; }
.export-spine-images { width: 100%; padding: 8px; border: 1px solid #3b5f8b; border-radius: 7px; background: #243e64; color: #eaf3ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-images:hover:not(:disabled) { border-color: #6099dc; background: #315581; }
.export-spine-images:disabled { cursor: wait; opacity: .55; }
.connector-status { display: flex; align-items: center; justify-content: center; gap: 5px; padding-top: 6px; color: #717b99; font-size: 10px; }
.connector-status .connector-dot { width: 6px; height: 6px; border-radius: 50%; background: #66718f; }
.connector-status.ready { color: #70d8ae; }
.connector-status.ready .connector-dot { background: #49c993; box-shadow: 0 0 5px #49c99388; }
.connector-status.warning { color: #d4aa67; }
.connector-status.warning .connector-dot { background: #d4aa67; }
.connector-status button { padding: 0 3px; border: 0; background: transparent; color: #8290b6; font-size: 10px; cursor: pointer; }
.connector-status button:hover { color: #c5caff; }
.connector-status a { padding: 0 3px; color: #a8b0ff; font-size: 10px; text-decoration: underline; }
.connector-status a:hover { color: #d8dcff; }
.export-message { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
.export-message.error { color: #ff858f; }
.export-message a { display: inline-block; margin-left: 5px; color: #9da7ff; text-decoration: underline; }
/* 外观与资源 参考版式 */
.look-title { font-size: 12px; font-weight: 600; color: #ccd; margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #2a2a3c; }
.look-title:first-child { margin-top: 2px; }
+27
View File
@@ -0,0 +1,27 @@
@echo off
chcp 65001 >nul
setlocal
set "PROJECT_DIR=%~dp0"
set "PID_FILE=%PROJECT_DIR%.run\spine-connector-windows.pid"
set "CONNECTOR_PID="
if exist "%PID_FILE%" set /p CONNECTOR_PID=<"%PID_FILE%"
powershell -NoProfile -Command ^
"$pidValue=0; [void][int]::TryParse($env:CONNECTOR_PID,[ref]$pidValue);" ^
"$process=if($pidValue -gt 0){Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $pidValue) -ErrorAction SilentlyContinue};" ^
"if($null -eq $process){$listener=Get-NetTCPConnection -LocalPort 27843 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if($listener){$process=Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $listener.OwningProcess) -ErrorAction SilentlyContinue}};" ^
"if ($null -eq $process) { exit 2 };" ^
"$expected=(Join-Path $env:PROJECT_DIR 'connector\spine-connector.mjs');" ^
"if (($process.CommandLine -notlike ('*' + $expected + '*')) -and ($process.CommandLine -notlike '*connector/spine-connector.mjs*') -and ($process.CommandLine -notlike '*connector\spine-connector.mjs*')) { exit 3 };" ^
"Stop-Process -Id $process.ProcessId -Force"
set "STOP_RESULT=%ERRORLEVEL%"
del /q "%PID_FILE%" >nul 2>&1
if "%STOP_RESULT%"=="3" (
echo 27843 端口对应的进程不是 Spine粒子连接器,为避免误关,本次未执行停止。
pause
exit /b 1
)
echo Spine粒子连接器已停止。
exit /b 0
+38
View File
@@ -0,0 +1,38 @@
#!/bin/zsh
set -u
PROJECT_DIR="${0:A:h}"
PID_FILE="$PROJECT_DIR/.run/spine-connector.pid"
CONNECTOR_PID=""
if [[ -f "$PID_FILE" ]]; then CONNECTOR_PID="$(tr -dc '0-9' < "$PID_FILE")"; fi
# PID 文件可能因手动启动、异常退出或脚本升级而缺失。此时回查连接器固定端口。
if [[ -z "$CONNECTOR_PID" ]] || ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then
CONNECTOR_PID="$(lsof -tiTCP:27843 -sTCP:LISTEN 2>/dev/null | head -n 1)"
fi
if [[ -z "$CONNECTOR_PID" ]] || ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then
rm -f "$PID_FILE"
echo "Spine粒子连接器已经停止。"
exit 0
fi
COMMAND="$(ps -p "$CONNECTOR_PID" -o command= 2>/dev/null)"
PROCESS_CWD="$(lsof -a -p "$CONNECTOR_PID" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
if [[ "$COMMAND" != *"connector/spine-connector.mjs"* ]] || [[ "$PROCESS_CWD" != "$PROJECT_DIR" ]]; then
echo "27843 端口对应的进程不属于当前项目,为避免误关,本次未执行停止。"
echo "进程目录:${PROCESS_CWD:-未知}"
rm -f "$PID_FILE"
exit 1
fi
kill "$CONNECTOR_PID" 2>/dev/null || true
for _ in {1..30}; do
if ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then break; fi
sleep 0.1
done
if kill -0 "$CONNECTOR_PID" 2>/dev/null; then kill -KILL "$CONNECTOR_PID" 2>/dev/null || true; fi
rm -f "$PID_FILE"
echo "Spine粒子连接器已停止。"
+45
View File
@@ -0,0 +1,45 @@
@echo off
chcp 65001 >nul
setlocal
set "PROJECT_DIR=%~dp0"
set "RUN_DIR=%PROJECT_DIR%.run"
set "PID_FILE=%RUN_DIR%\spine-connector-windows.pid"
set "OUT_LOG=%RUN_DIR%\spine-connector-windows.log"
set "ERROR_LOG=%RUN_DIR%\spine-connector-windows-error.log"
if not exist "%RUN_DIR%" mkdir "%RUN_DIR%"
cd /d "%PROJECT_DIR%"
if exist "%PID_FILE%" (
set /p CONNECTOR_PID=<"%PID_FILE%"
powershell -NoProfile -Command "if (Get-Process -Id $env:CONNECTOR_PID -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"
if not errorlevel 1 (
echo Spine粒子连接器已经在运行。
exit /b 0
)
del /q "%PID_FILE%" >nul 2>&1
)
where node >nul 2>&1
if errorlevel 1 (
echo 未找到 Node.js,请先安装 Node.js 18 或更高版本。
pause
exit /b 1
)
echo 正在启动 Spine粒子连接器...
powershell -NoProfile -Command ^
"$node=(Get-Command node).Source; $entry=Join-Path $env:PROJECT_DIR 'connector\spine-connector.mjs';" ^
"$process=Start-Process -FilePath $node -ArgumentList @($entry) -WorkingDirectory $env:PROJECT_DIR -RedirectStandardOutput $env:OUT_LOG -RedirectStandardError $env:ERROR_LOG -WindowStyle Hidden -PassThru;" ^
"Set-Content -Path $env:PID_FILE -Value $process.Id -Encoding ascii"
if errorlevel 1 (
echo 连接器启动失败。
pause
exit /b 1
)
timeout /t 2 /nobreak >nul
echo Spine粒子连接器已启动,可以回到网页直接导出 .spine。
exit /b 0
+52
View File
@@ -0,0 +1,52 @@
#!/bin/zsh
set -u
PROJECT_DIR="${0:A:h}"
RUN_DIR="$PROJECT_DIR/.run"
PID_FILE="$RUN_DIR/spine-connector.pid"
LOG_FILE="$RUN_DIR/spine-connector.log"
HEALTH_URL="http://127.0.0.1:27843/v1/health"
mkdir -p "$RUN_DIR"
cd "$PROJECT_DIR" || exit 1
if [[ -f "$PID_FILE" ]]; then
CONNECTOR_PID="$(tr -dc '0-9' < "$PID_FILE")"
if [[ -n "$CONNECTOR_PID" ]] && kill -0 "$CONNECTOR_PID" 2>/dev/null; then
echo "Spine粒子连接器已经在运行。"
exit 0
fi
rm -f "$PID_FILE"
fi
if ! command -v node >/dev/null 2>&1; then
echo "未找到 Node.js,请先安装 Node.js 18 或更高版本。"
read "?按回车键关闭窗口..."
exit 1
fi
echo "正在启动 Spine粒子连接器..."
# 关闭标准输入并让 zsh 立即脱离任务,避免双击脚本的 Terminal 窗口关闭时带走连接器。
nohup node "$PROJECT_DIR/connector/spine-connector.mjs" > "$LOG_FILE" 2>&1 < /dev/null &!
CONNECTOR_PID=$!
echo "$CONNECTOR_PID" > "$PID_FILE"
for _ in {1..40}; do
if ! kill -0 "$CONNECTOR_PID" 2>/dev/null; then
echo "连接器启动失败:"
tail -n 30 "$LOG_FILE"
rm -f "$PID_FILE"
read "?按回车键关闭窗口..."
exit 1
fi
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
echo "Spine粒子连接器启动成功,可以回到网页直接导出 .spine。"
exit 0
fi
sleep 0.25
done
echo "连接器启动超时,请查看日志:$LOG_FILE"
read "?按回车键关闭窗口..."
exit 1