导出优化

This commit is contained in:
tianmo
2026-09-05 18:45:43 +08:00
parent 72d8daa283
commit 79ce3165c1
17 changed files with 1559 additions and 1291 deletions
+10 -6
View File
@@ -3,6 +3,7 @@ import { constants } from 'node:fs'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
import { createSpine38ImportOptions } from './spine38-project.mjs'
const SUPPORTED_VERSIONS = new Set(['3.8', '4.0', '4.1', '4.2', '4.3'])
@@ -42,9 +43,9 @@ export function normalizeSpineVersion(value) {
return match[1]
}
function run(executablePath, args, timeoutMs = 180000) {
function run(executablePath, args, { cwd, timeoutMs = 180000 } = {}) {
return new Promise((resolve, reject) => {
execFile(executablePath, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
execFile(executablePath, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
const details = [stdout, stderr, error.message]
.filter(Boolean)
@@ -59,15 +60,18 @@ function run(executablePath, args, timeoutMs = 180000) {
})
}
export async function importSpineProject({ executablePath, inputPath, outputPath, projectName, spineVersion }) {
export async function importSpineProject({ executablePath, inputPath, outputPath, projectName, spineVersion, workspaceRoot }) {
const version = normalizeSpineVersion(spineVersion)
const editorVersion = `${version}.xx`
const importOptions = version === '3.8'
? createSpine38ImportOptions({ workspaceRoot, inputPath, outputPath })
: { inputPath, outputPath }
const result = await run(executablePath, [
'--update', editorVersion,
'--input', inputPath,
'--output', outputPath,
'--input', importOptions.inputPath,
'--output', importOptions.outputPath,
'--import', projectName,
])
], { cwd: importOptions.cwd })
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
+1
View File
@@ -105,6 +105,7 @@ const server = createServer(async (request, response) => {
outputPath: workspace.outputPath,
projectName: workspace.projectName,
spineVersion,
workspaceRoot: workspace.root,
})
const project = await workspace.readOutput()
const projectFileName = `${safeProjectName(workspace.projectName)}.spine`
+6 -1
View File
@@ -1,6 +1,7 @@
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
@@ -49,7 +50,11 @@ export async function createConversionWorkspace(payload) {
const projectName = safeProjectName(payload.projectName)
const inputPath = join(root, `${projectName}.json`)
const outputPath = join(root, `${projectName}.spine`)
await writeFile(inputPath, payload.skeletonJson, 'utf8')
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 : []) {
+34
View File
@@ -0,0 +1,34 @@
import { relative, sep } from 'node:path'
function relativeWorkspacePath(workspaceRoot, targetPath) {
const path = relative(workspaceRoot, targetPath)
if (!path || path === '..' || path.startsWith(`..${sep}`)) {
throw new Error('Spine 3.8 工程文件必须位于连接器工作目录内')
}
return path
}
export function normalizeSpine38ImagesPath(imagesDirectory) {
const path = String(imagesDirectory || 'images')
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/\/+$/, '')
return path || 'images'
}
export function prepareSpine38SkeletonJson(skeleton, imagesDirectory) {
const prepared = JSON.parse(JSON.stringify(skeleton))
prepared.skeleton ||= {}
// Spine 3.8 的命令行导入器对 "./images/" 的处理并不稳定,某些工程会
// 将其固化为导入时的临时绝对路径。使用无前缀、无尾斜杠的工程内路径。
prepared.skeleton.images = normalizeSpine38ImagesPath(imagesDirectory)
return `${JSON.stringify(prepared, null, 2)}\n`
}
export function createSpine38ImportOptions({ workspaceRoot, inputPath, outputPath }) {
return {
cwd: workspaceRoot,
inputPath: relativeWorkspacePath(workspaceRoot, inputPath),
outputPath: relativeWorkspacePath(workspaceRoot, outputPath),
}
}
File diff suppressed because one or more lines are too long
+1239
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1239
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-qzW_me3K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3l7sHON.css">
<script type="module" crossorigin src="/assets/index-B_G0a-2w.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D5HEdVAv.css">
</head>
<body>
<div id="app"></div>
+1
View File
@@ -12,6 +12,7 @@
"test:timeline": "node scripts/verify-timeline.mjs",
"test:spine-json": "node scripts/verify-spine-json.mjs",
"test:connector": "node scripts/verify-spine-connector.mjs",
"test:connector:spine38-path": "node scripts/verify-spine38-connector-path.mjs",
"test:connector:sea": "node scripts/verify-connector-sea.mjs",
"test:connector-versions": "node scripts/verify-spine-connector-versions.mjs",
"preview": "vite preview"
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { access, readFile } from 'node:fs/promises'
import { createSpine38ImportOptions, prepareSpine38SkeletonJson } from '../connector/spine38-project.mjs'
import { createConversionWorkspace } from '../connector/spine-workspace.mjs'
const source = {
skeleton: { spine: '3.8', images: './images/', fps: 30 },
bones: [{ name: 'root' }],
slots: [],
skins: { default: {} },
animations: { animation: {} },
}
const prepared = JSON.parse(prepareSpine38SkeletonJson(source, 'images'))
assert.equal(prepared.skeleton.images, 'images')
assert.equal(source.skeleton.images, './images/')
const workspace = await createConversionWorkspace({
schemaVersion: 1,
projectName: 'Spine38PathTest',
spineVersion: '3.8',
skeletonJson: JSON.stringify(source),
images: [],
})
try {
const written = JSON.parse(await readFile(workspace.inputPath, 'utf8'))
assert.equal(written.skeleton.images, 'images')
await access(`${workspace.root}/images`)
const invocation = createSpine38ImportOptions({
workspaceRoot: workspace.root,
inputPath: workspace.inputPath,
outputPath: workspace.outputPath,
})
assert.equal(invocation.cwd, workspace.root)
assert.equal(invocation.inputPath, 'Spine38PathTest.json')
assert.equal(invocation.outputPath, 'Spine38PathTest.spine')
console.log('Spine 3.8 图片路径预处理与相对路径导入检查通过')
} finally {
await workspace.cleanup()
}
+2
View File
@@ -15,6 +15,7 @@
</div>
</div>
</div>
<TransferProgressOverlay />
</template>
<script setup lang="ts">
@@ -24,6 +25,7 @@ import { installSystemSettingsCache } from './editor/systemSettings'
import Stage from './views/Stage.vue'
import ParticlePanel from './views/ParticlePanel.vue'
import Timeline from './views/Timeline.vue'
import TransferProgressOverlay from './views/TransferProgressOverlay.vue'
const store = useParticleStore()
installSystemSettingsCache(store)
+94
View File
@@ -0,0 +1,94 @@
import { reactive } from 'vue'
export interface TransferProgressState {
visible: boolean
title: string
detail: string
progress: number
indeterminate: boolean
failed: boolean
}
export const transferProgress = reactive<TransferProgressState>({
visible: false,
title: '',
detail: '',
progress: 0,
indeterminate: false,
failed: false,
})
let operationId = 0
function delay(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
}
/** 管理全屏传输提示;新操作会自动取代尚未结束的旧操作。 */
export function beginTransfer(title: string, detail: string) {
const id = ++operationId
const startedAt = performance.now()
Object.assign(transferProgress, {
visible: true,
title,
detail,
progress: 0,
indeterminate: false,
failed: false,
})
const update = (progress: number | null, nextDetail?: string) => {
if (id !== operationId) return
transferProgress.indeterminate = progress == null
if (progress != null) transferProgress.progress = Math.min(100, Math.max(0, progress))
if (nextDetail) transferProgress.detail = nextDetail
}
const closeAfter = async (failed: boolean, nextDetail: string) => {
if (id !== operationId) return
transferProgress.failed = failed
transferProgress.indeterminate = false
transferProgress.progress = failed ? transferProgress.progress : 100
transferProgress.detail = nextDetail
const minimumVisible = failed ? 850 : 450
await delay(Math.max(180, minimumVisible - (performance.now() - startedAt)))
if (id === operationId) transferProgress.visible = false
}
return {
update,
/** 让初始状态先绘制出来,再执行可能阻塞主线程的序列化。 */
paint: () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
finish: (nextDetail = '已完成') => closeAfter(false, nextDetail),
fail: (nextDetail = '操作失败') => closeAfter(true, nextDetail),
}
}
/** 读取 HTTP 响应并按 Content-Length 汇报真实下载进度。 */
export async function readResponseTextWithProgress(
response: Response,
onProgress: (progress: number | null) => void,
) {
if (!response.body) {
onProgress(null)
return response.text()
}
const total = Number(response.headers.get('content-length'))
const hasTotal = Number.isFinite(total) && total > 0
const reader = response.body.getReader()
const decoder = new TextDecoder()
let loaded = 0
let text = ''
onProgress(hasTotal ? 0 : null)
while (true) {
const { done, value } = await reader.read()
if (done) break
loaded += value.byteLength
text += decoder.decode(value, { stream: true })
onProgress(hasTotal ? Math.min(1, loaded / total) : null)
}
text += decoder.decode()
if (hasTotal) onProgress(1)
return text
}
+61 -37
View File
@@ -18,6 +18,11 @@ export interface ConnectorImage {
dataBase64: string
}
export interface SpineConnectorProgress {
phase: 'images' | 'upload' | 'processing' | 'download'
progress: number | null
}
interface ConnectorErrorBody {
code?: string
message?: string
@@ -74,21 +79,26 @@ async function sourceToImage(source: string, folder: string, name: string): Prom
}
/** 收集 Spine JSON 中附件会引用的粒子与拖尾图片。 */
export async function collectSpineExportImages(systems: ParticleSystem[]) {
export async function collectSpineExportImages(systems: ParticleSystem[], onProgress?: (completed: number, total: number) => void) {
const requests: Array<Promise<ConnectorImage | null>> = []
let completed = 0
const track = (request: Promise<ConnectorImage | null>) => request.finally(() => {
completed++
onProgress?.(completed, requests.length)
})
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))
requests.push(track(sourceToImage(frame.previewUrl, resource.textureFolder, frame.name || resource.textureName)))
}
} else {
requests.push(sourceToImage(resource.previewUrl, resource.textureFolder, resource.textureName))
requests.push(track(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))
requests.push(track(sourceToImage(resource.previewUrl, '', resource.textureName)))
}
}
}
@@ -113,39 +123,53 @@ export async function convertWithSpineConnector(input: {
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,
}
}, onProgress?: (progress: SpineConnectorProgress) => void) {
const images = await collectSpineExportImages(input.systems, (completed, total) => {
onProgress?.({ phase: 'images', progress: total ? completed / total : 1 })
})
const body = JSON.stringify({
schemaVersion: 1,
projectName: input.projectName,
spineVersion: input.spineVersion,
skeletonJson: input.skeletonJson,
images,
})
return new Promise<{ blob: Blob; fileName: string; spineEditorVersion: string; imageCount: number }>((resolve, reject) => {
const request = new XMLHttpRequest()
request.open('POST', `${CONNECTOR_URL}/v1/convert`)
request.responseType = 'blob'
request.setRequestHeader('Content-Type', 'application/json')
request.upload.onprogress = (event) => {
onProgress?.({ phase: 'upload', progress: event.lengthComputable && event.total > 0 ? event.loaded / event.total : null })
}
request.upload.onloadend = () => onProgress?.({ phase: 'processing', progress: null })
request.onprogress = (event) => {
onProgress?.({ phase: 'download', progress: event.lengthComputable && event.total > 0 ? event.loaded / event.total : null })
}
request.onerror = () => reject(new SpineConnectorError('未检测到本地 Spine 连接器,请先启动连接器', 'CONNECTOR_OFFLINE'))
request.onload = async () => {
const responseBlob = request.response as Blob
if (request.status < 200 || request.status >= 300) {
let errorBody: ConnectorErrorBody = {}
try { errorBody = JSON.parse(await responseBlob.text()) as ConnectorErrorBody } catch { /* 使用 HTTP 回退信息 */ }
reject(new SpineConnectorError(errorBody.message || `Spine 连接器返回错误:HTTP ${request.status}`, errorBody.code, errorBody.pairUrl))
return
}
const encodedFileName = request.getResponseHeader('X-Spine-File-Name')
let fileName = `${cleanPart(input.projectName, 'SpineParticle')}.zip`
if (encodedFileName) {
try { fileName = decodeURIComponent(encodedFileName) } catch { /* 保留安全的默认文件名 */ }
}
resolve({
blob: responseBlob,
fileName,
spineEditorVersion: request.getResponseHeader('X-Spine-Editor-Version') || input.spineVersion,
imageCount: images.length,
})
}
request.send(body)
})
}
export function downloadSpineProject(blob: Blob, fileName: string) {
+26 -3
View File
@@ -1002,6 +1002,7 @@ import ColorGradientEditor from './ColorGradientEditor.vue'
import ImageResourceCard from './ImageResourceCard.vue'
import SpineObjectPanel from '../spine/SpineObjectPanel.vue'
import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from '../editor/editorConfig'
import { beginTransfer, readResponseTextWithProgress } from '../editor/transferProgress'
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
import { validateSpineJson } from '../export/spineJsonValidator'
@@ -1112,15 +1113,24 @@ async function loadSelectedPreset() {
presetLoading.value = true
configError.value = false
configMessage.value = '正在加载预设…'
const transfer = beginTransfer('加载预设', `正在下载 ${file}`)
try {
await transfer.paint()
const response = await fetch(`/Preset/${encodeURIComponent(file)}`, { cache: 'no-store' })
if (!response.ok) throw new Error(`无法读取预设:${file}`)
await loadEditorConfigText(store, await response.text())
const text = await readResponseTextWithProgress(response, (progress) => {
transfer.update(progress == null ? null : progress * 55, `正在下载 ${file}`)
})
transfer.update(58, '正在解析预设配置…')
transfer.update(70, '正在解析并恢复预设资源…')
await loadEditorConfigText(store, text)
configMessage.value = `已加载预设:${file.replace(/\.json$/i, '')}`
await transfer.finish('预设加载完成')
} catch (error) {
configError.value = true
const message = error instanceof Error ? error.message : String(error)
configMessage.value = `${message},请检查预设文件命名`
await transfer.fail(message)
} finally {
presetLoading.value = false
selectedPreset.value = ''
@@ -1146,7 +1156,7 @@ function nextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function prepareExportFrames() {
async function prepareExportFrames(onProgress?: (progress: number) => void) {
store.timeline.playing = false
store.timeline.frame = 0
store.timeline.recorded = false
@@ -1158,8 +1168,10 @@ async function prepareExportFrames() {
const deadline = performance.now() + Math.max(8000, expectedMs * 2.5 + 3000)
while (!store.timeline.recorded) {
if (performance.now() > deadline) throw new Error('重新计算动画超时,请确认页面仍在运行后重试')
onProgress?.(Math.min(0.98, store.timeline.frame / Math.max(1, store.timeline.totalFrames - 1)))
await new Promise((resolve) => setTimeout(resolve, 50))
}
onProgress?.(1)
}
async function exportSpineJson() {
@@ -1192,8 +1204,11 @@ async function exportSpineProject() {
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在重新计算粒子动画…'
const transfer = beginTransfer('导出 Spine 包', '正在重新计算粒子动画…')
try {
await prepareExportFrames()
await transfer.paint()
await prepareExportFrames((progress) => transfer.update(progress * 18, '正在重新计算粒子动画…'))
transfer.update(20, '正在生成 Spine JSON…')
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…'
@@ -1203,15 +1218,23 @@ async function exportSpineProject() {
spineVersion: exportSettings.value.spineVersion,
skeletonJson: result.text,
systems: store.systems as unknown as ParticleSystem[],
}, ({ phase, progress }) => {
if (phase === 'images') transfer.update(progress == null ? null : 22 + progress * 18, '正在整理工程图片…')
else if (phase === 'upload') transfer.update(progress == null ? null : 40 + progress * 30, '正在上传数据到 Spine 连接器…')
else if (phase === 'processing') transfer.update(null, 'Spine 正在转换工程,请稍候…')
else transfer.update(progress == null ? null : 82 + progress * 16, '正在下载生成的 Spine 工程包…')
})
transfer.update(99, '正在交给浏览器下载…')
downloadSpineProject(converted.blob, converted.fileName)
exportMessage.value = `已生成 ${converted.fileName}:包含 .spine 工程和 ${converted.imageCount} 张图片,Spine ${converted.spineEditorVersion}`
await refreshConnectorHealth()
await transfer.finish('Spine 工程包已开始下载')
} 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()
await transfer.fail(exportMessage.value)
} finally {
exportBusy.value = false
exportTarget.value = null
+38
View File
@@ -0,0 +1,38 @@
<template>
<Transition name="transfer-fade">
<div v-if="transferProgress.visible" class="transfer-overlay" role="status" aria-live="polite">
<div class="transfer-dialog" :class="{ failed: transferProgress.failed }">
<div class="transfer-heading">
<span>{{ transferProgress.title }}</span>
<strong v-if="!transferProgress.indeterminate">{{ Math.round(transferProgress.progress) }}%</strong>
</div>
<div class="transfer-track" :class="{ indeterminate: transferProgress.indeterminate }">
<div class="transfer-bar" :style="transferProgress.indeterminate ? undefined : { width: `${transferProgress.progress}%` }"></div>
</div>
<div class="transfer-detail">{{ transferProgress.detail }}</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { transferProgress } from '../editor/transferProgress'
</script>
<style scoped>
.transfer-overlay { position: fixed; z-index: 2000; inset: 0; display: grid; place-items: center; pointer-events: none; background: rgba(5, 8, 15, 0.26); backdrop-filter: blur(1px); }
.transfer-dialog { width: min(420px, calc(100vw - 40px)); padding: 18px 20px 16px; border: 1px solid #414b66; border-radius: 10px; box-shadow: 0 18px 55px rgba(0, 0, 0, 0.46); background: #171d2b; color: #e7ebf5; }
.transfer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; font-size: 14px; font-weight: 700; }
.transfer-heading strong { color: #a8a6ff; font-size: 12px; font-variant-numeric: tabular-nums; }
.transfer-track { height: 9px; overflow: hidden; border: 1px solid #35415a; border-radius: 999px; background: #0d1422; }
.transfer-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4d75e8, #7774ff); transition: width .16s ease; }
.transfer-track.indeterminate .transfer-bar { width: 42%; animation: transfer-slide 1s ease-in-out infinite; }
.transfer-detail { min-height: 17px; margin-top: 9px; color: #8895ae; font-size: 11px; line-height: 1.5; }
.transfer-dialog.failed { border-color: #754351; }
.transfer-dialog.failed .transfer-heading strong, .transfer-dialog.failed .transfer-detail { color: #e99aa9; }
.transfer-dialog.failed .transfer-bar { background: #c85d70; }
.transfer-fade-enter-active, .transfer-fade-leave-active { transition: opacity .16s ease; }
.transfer-fade-enter-from, .transfer-fade-leave-to { opacity: 0; }
@keyframes transfer-slide { from { transform: translateX(-110%); } to { transform: translateX(250%); } }
</style>