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

+161
View File
@@ -0,0 +1,161 @@
import type { ParticleSystem } from '../store/particleStore'
import type { SpineExportVersion } from './spineExportSettings'
const CONNECTOR_URL = 'http://127.0.0.1:27843'
export interface SpineConnectorHealth {
available: boolean
authorized: boolean
connectorVersion?: string
spineFound?: boolean
spinePath?: string
platform?: string
error?: string
}
export interface ConnectorImage {
path: string
dataBase64: string
}
interface ConnectorErrorBody {
code?: string
message?: string
pairUrl?: string
}
export class SpineConnectorError extends Error {
code: string
pairUrl?: string
constructor(message: string, code = 'CONNECTOR_ERROR', pairUrl?: string) {
super(message)
this.name = 'SpineConnectorError'
this.code = code
this.pairUrl = pairUrl
}
}
function cleanPart(value: string, fallback: string) {
const cleaned = String(value || '').trim().replace(/[\\/:*?"<>|]+/g, '_').replace(/^\.+/, '')
return cleaned || fallback
}
function extensionFor(name: string, contentType: string) {
const match = /\.([a-z0-9]{2,5})$/i.exec(name)
if (match) return `.${match[1].toLowerCase()}`
if (contentType.includes('jpeg')) return '.jpg'
if (contentType.includes('webp')) return '.webp'
if (contentType.includes('gif')) return '.gif'
return '.png'
}
function imagePath(folder: string, name: string, contentType: string) {
const parts = String(folder || '').replace(/\\/g, '/').split('/').filter(Boolean).map((part) => cleanPart(part, 'images'))
const base = cleanPart(name.replace(/\.[^./\\]+$/, ''), 'image')
return [...parts, `${base}${extensionFor(name, contentType)}`].join('/')
}
async function sourceToImage(source: string, folder: string, name: string): Promise<ConnectorImage | null> {
if (!source) return null
const response = await fetch(source)
if (!response.ok) throw new Error(`无法读取导出图片:${name}`)
const blob = await response.blob()
const bytes = new Uint8Array(await blob.arrayBuffer())
let binary = ''
const chunkSize = 0x8000
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))
}
return {
path: imagePath(folder, name, blob.type),
dataBase64: btoa(binary),
}
}
/** 收集 Spine JSON 中附件会引用的粒子与拖尾图片。 */
export async function collectSpineExportImages(systems: ParticleSystem[]) {
const requests: Array<Promise<ConnectorImage | null>> = []
for (const system of systems.filter((item) => item.visible !== false)) {
for (const resource of system.config.imageResources) {
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
for (const frame of resource.sequenceFrames) {
requests.push(sourceToImage(frame.previewUrl, resource.textureFolder, frame.name || resource.textureName))
}
} else {
requests.push(sourceToImage(resource.previewUrl, resource.textureFolder, resource.textureName))
}
}
if (system.config.trail) {
for (const resource of system.config.trailResources) {
requests.push(sourceToImage(resource.previewUrl, '', resource.textureName))
}
}
}
const unique = new Map<string, ConnectorImage>()
for (const image of await Promise.all(requests)) if (image && !unique.has(image.path)) unique.set(image.path, image)
return [...unique.values()]
}
export async function checkSpineConnector(): Promise<SpineConnectorHealth> {
try {
const response = await fetch(`${CONNECTOR_URL}/v1/health`, { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = await response.json()
return { available: true, ...body }
} catch (error) {
return { available: false, authorized: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function convertWithSpineConnector(input: {
projectName: string
spineVersion: SpineExportVersion
skeletonJson: string
systems: ParticleSystem[]
}) {
const images = await collectSpineExportImages(input.systems)
let response: Response
try {
response = await fetch(`${CONNECTOR_URL}/v1/convert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schemaVersion: 1,
projectName: input.projectName,
spineVersion: input.spineVersion,
skeletonJson: input.skeletonJson,
images,
}),
})
} catch {
throw new SpineConnectorError('未检测到本地 Spine 连接器,请先启动连接器', 'CONNECTOR_OFFLINE')
}
if (!response.ok) {
const body = await response.json().catch(() => ({} as ConnectorErrorBody)) as ConnectorErrorBody
throw new SpineConnectorError(body.message || `Spine 连接器返回错误:HTTP ${response.status}`, body.code, body.pairUrl)
}
const encodedFileName = response.headers.get('X-Spine-File-Name')
let fileName = `${cleanPart(input.projectName, 'SpineParticle')}.zip`
if (encodedFileName) {
try { fileName = decodeURIComponent(encodedFileName) } catch { /* 保留安全的默认文件名 */ }
}
return {
blob: await response.blob(),
fileName,
spineEditorVersion: response.headers.get('X-Spine-Editor-Version') || input.spineVersion,
imageCount: images.length,
}
}
export function downloadSpineProject(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = /\.zip$/i.test(fileName) ? fileName : `${fileName}.zip`
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+47
View File
@@ -0,0 +1,47 @@
import type { ParticleSystem } from '../store/particleStore'
import { collectSpineExportImages } from './spineConnectorClient'
import { createZipArchive } from './zipArchive'
function decodeBase64(value: string) {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
return bytes
}
function imagesDirectory(value: string) {
const path = String(value || './images/').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
const parts = path.split('/').filter(Boolean)
if (!parts.length || parts.some((part) => part === '..' || /^[a-z]+:/i.test(part))) return 'images'
return parts.join('/')
}
function safeFileName(value: string) {
const name = String(value || 'SpineParticle').replace(/\.json$/i, '').replace(/[\\/:*?"<>|]+/g, '_').replace(/^\.+/, '')
return name || 'SpineParticle'
}
export async function buildSpineImagesArchive(input: { systems: ParticleSystem[]; imagesPath: string }) {
const images = await collectSpineExportImages(input.systems)
if (!images.length) throw new Error('当前可见粒子系统没有可导出的图片')
const folder = imagesDirectory(input.imagesPath)
const blob = createZipArchive(images.map((image) => ({
name: `${folder}/${image.path}`,
data: decodeBase64(image.dataBase64),
})))
return { blob, imageCount: images.length, folder }
}
export function downloadSpineImagesArchive(blob: Blob, exportFileName: string) {
const fileName = `${safeFileName(exportFileName)}-images.zip`
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
return fileName
}
+91
View File
@@ -0,0 +1,91 @@
export interface ZipArchiveFile {
name: string
data: Uint8Array
}
const crcTable = Array.from({ length: 256 }, (_, value) => {
let crc = value
for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1
return crc >>> 0
})
function crc32(data: Uint8Array) {
let crc = 0xffffffff
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
return (crc ^ 0xffffffff) >>> 0
}
function safeArchivePath(value: string) {
const parts = String(value || '').replace(/\\/g, '/').split('/').filter((part) => part && part !== '.')
if (!parts.length || parts.some((part) => part === '..')) throw new Error('ZIP 文件路径无效')
return parts.join('/')
}
function dateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear())
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
}
}
function header(size: number) {
const bytes = new Uint8Array(size)
return { bytes, view: new DataView(bytes.buffer) }
}
function combine(chunks: Uint8Array[]) {
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
let offset = 0
for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length }
return output
}
/** 创建 Store 模式 ZIP;图片本身已经压缩,避免引入额外打包依赖。 */
export function createZipArchive(files: ZipArchiveFile[]) {
const encoder = new TextEncoder()
const localChunks: Uint8Array[] = []
const centralChunks: Uint8Array[] = []
const timestamp = dateTime()
let offset = 0
for (const file of files) {
const name = encoder.encode(safeArchivePath(file.name))
const crc = crc32(file.data)
const local = header(30)
local.view.setUint32(0, 0x04034b50, true)
local.view.setUint16(4, 20, true)
local.view.setUint16(6, 0x0800, true)
local.view.setUint16(10, timestamp.time, true)
local.view.setUint16(12, timestamp.date, true)
local.view.setUint32(14, crc, true)
local.view.setUint32(18, file.data.length, true)
local.view.setUint32(22, file.data.length, true)
local.view.setUint16(26, name.length, true)
localChunks.push(local.bytes, name, file.data)
const central = header(46)
central.view.setUint32(0, 0x02014b50, true)
central.view.setUint16(4, 20, true)
central.view.setUint16(6, 20, true)
central.view.setUint16(8, 0x0800, true)
central.view.setUint16(12, timestamp.time, true)
central.view.setUint16(14, timestamp.date, true)
central.view.setUint32(16, crc, true)
central.view.setUint32(20, file.data.length, true)
central.view.setUint32(24, file.data.length, true)
central.view.setUint16(28, name.length, true)
central.view.setUint32(42, offset, true)
centralChunks.push(central.bytes, name)
offset += local.bytes.length + name.length + file.data.length
}
const centralDirectory = combine(centralChunks)
const end = header(22)
end.view.setUint32(0, 0x06054b50, true)
end.view.setUint16(8, files.length, true)
end.view.setUint16(10, files.length, true)
end.view.setUint32(12, centralDirectory.length, true)
end.view.setUint32(16, offset, true)
return new Blob([combine([...localChunks, centralDirectory, end.bytes])], { type: 'application/zip' })
}