导出优化
This commit is contained in:
+10
-6
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 : []) {
|
||||
|
||||
@@ -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
Vendored
+1239
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
Vendored
-1239
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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