导出优化

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
+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>