spine连接器和导出修改

This commit is contained in:
tianmo committed 2026-09-05 16:20:23 +08:00
1 parent 535f8d7558
commit 0bde824c88
81 files changed
+11800 -1251

No files matched your search

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