spine连接器和导出修改
@@ -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 提示。
|
||||||
@@ -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 重新签名并公证。
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 停止连接器')
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建无额外依赖的 ZIP(Store 模式);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])
|
||||||
|
}
|
||||||
@@ -3,8 +3,16 @@
|
|||||||
"name": "单个烟花",
|
"name": "单个烟花",
|
||||||
"file": "单个烟花.json"
|
"file": "单个烟花.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "雷电",
|
||||||
|
"file": "雷电.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
|
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
|
||||||
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
|
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "烟花金币spine",
|
||||||
|
"file": "烟花金币spine.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
|
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; }
|
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-CPFl-YCv.js"></script>
|
<script type="module" crossorigin src="/assets/index-C87f76bX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-n3FHecj5.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CJwt9TyR.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 142 KiB |
@@ -6,9 +6,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"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:loop-seam": "node scripts/verify-loop-seam.mjs",
|
||||||
"test:timeline": "node scripts/verify-timeline.mjs",
|
"test:timeline": "node scripts/verify-timeline.mjs",
|
||||||
"test:spine-json": "node scripts/verify-spine-json.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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
"name": "单个烟花",
|
"name": "单个烟花",
|
||||||
"file": "单个烟花.json"
|
"file": "单个烟花.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "雷电",
|
||||||
|
"file": "雷电.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
|
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
|
||||||
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
|
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 142 KiB |
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
@@ -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 范围与开始/循环/结束三段边界校验通过')
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -900,15 +900,32 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
|
<div class="spine-export-actions">
|
||||||
{{ exportBusy ? '正在准备导出…' : '导出Spine Json' }}
|
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
|
||||||
</button>
|
{{ exportBusy && exportTarget === 'json' ? '正在准备导出…' : '导出Spine Json' }}
|
||||||
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
|
</button>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Texture } from 'pixi.js'
|
||||||
import { useParticleStore } from '../store/particleStore'
|
import { useParticleStore } from '../store/particleStore'
|
||||||
import type { ParticleSystem } 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 { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
|
||||||
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
|
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
|
||||||
import { validateSpineJson } from '../export/spineJsonValidator'
|
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'
|
import { automaticLoopTransitionFrames } from '../core/loopSegmentTiming'
|
||||||
|
|
||||||
const store = useParticleStore()
|
const store = useParticleStore()
|
||||||
@@ -935,8 +960,43 @@ const presetLoading = ref(false)
|
|||||||
const configMessage = ref('')
|
const configMessage = ref('')
|
||||||
const configError = ref(false)
|
const configError = ref(false)
|
||||||
const exportBusy = ref(false)
|
const exportBusy = ref(false)
|
||||||
|
const exportTarget = ref<'json' | 'spine' | 'images' | null>(null)
|
||||||
const exportMessage = ref('')
|
const exportMessage = ref('')
|
||||||
const exportError = ref(false)
|
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: '跟随导出' | '线性' | '贝塞尔') {
|
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
|
||||||
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
|
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() {
|
function nextPaint() {
|
||||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
@@ -1027,7 +1100,9 @@ async function prepareExportFrames() {
|
|||||||
async function exportSpineJson() {
|
async function exportSpineJson() {
|
||||||
if (exportBusy.value) return
|
if (exportBusy.value) return
|
||||||
exportBusy.value = true
|
exportBusy.value = true
|
||||||
|
exportTarget.value = 'json'
|
||||||
exportError.value = false
|
exportError.value = false
|
||||||
|
connectorPairUrl.value = ''
|
||||||
exportMessage.value = '正在重新计算粒子动画…'
|
exportMessage.value = '正在重新计算粒子动画…'
|
||||||
try {
|
try {
|
||||||
await prepareExportFrames()
|
await prepareExportFrames()
|
||||||
@@ -1041,6 +1116,63 @@ async function exportSpineJson() {
|
|||||||
exportMessage.value = error instanceof Error ? error.message : String(error)
|
exportMessage.value = error instanceof Error ? error.message : String(error)
|
||||||
} finally {
|
} finally {
|
||||||
exportBusy.value = false
|
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-switches { display: grid; gap: 13px; margin: 4px 0 7px; padding-top: 2px; }
|
||||||
.export-switch { font-weight: 600; }
|
.export-switch { font-weight: 600; }
|
||||||
.export-switch-last { margin-top: 13px; }
|
.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:hover { border-color: #7774ff; background: #514bc0; }
|
||||||
.export-spine-json:disabled { cursor: wait; opacity: .68; }
|
.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 { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
|
||||||
.export-message.error { color: #ff858f; }
|
.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 { 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; }
|
.look-title:first-child { margin-top: 2px; }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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粒子连接器已停止。"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||