导出优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user