Files
2026-09-05 18:45:43 +08:00

85 lines
3.9 KiB
JavaScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, normalize, sep } from 'node:path'
import { prepareSpine38SkeletonJson } from './spine38-project.mjs'
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`)
const importJson = spineVersion === '3.8'
? prepareSpine38SkeletonJson(skeleton, imagesDirectory)
: payload.skeletonJson
await mkdir(join(root, imagesDirectory), { recursive: true })
await writeFile(inputPath, importJson, '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
}
}