diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d513d30..2e5a572 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "windup-frontend", "version": "0.0.0", "dependencies": { + "gifenc": "^1.0.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" @@ -19,6 +20,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", @@ -2202,6 +2204,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2406,6 +2425,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2439,6 +2482,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gifenc": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/gifenc/-/gifenc-1.0.3.tgz", + "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2525,6 +2574,13 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a845f7..4edb565 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc -b" }, "dependencies": { + "gifenc": "^1.0.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" @@ -25,6 +26,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", diff --git a/frontend/src/features/export-package/README.md b/frontend/src/features/export-package/README.md new file mode 100644 index 0000000..95c61e3 --- /dev/null +++ b/frontend/src/features/export-package/README.md @@ -0,0 +1,48 @@ +# Export Package 模块 + +本模块把已经通过质量检测的角色动作整理为可下载 ZIP。它不负责生成图片、保存历史记录或发布资产,只负责验证和导出。 + +## 数据怎么走 + +1. Playtest 或接口适配器组装 `ExportPackageModel`。 +2. `validateExportPackageModel` 检查角色、画布、生成记录、帧数、质量状态、锚点和脚底线。 +3. `createAssetExportPlan` 为每个动作方向生成稳定目录与三位连续帧名。 +4. `exportGameAssets` 读取透明 PNG,并检查图片尺寸是否与统一画布一致。 +5. 浏览器生成图集和 GIF,最后写入 `meta.json`、`schema.json`、README 与 ZIP。 +6. 可选 target 只在 `targets//` 下追加引擎文件,不修改通用层。 + +## 导出结构 + +```text +Aster-character-1/ + meta.json + schema.json + README.md + preview/Walk-south.gif + frames/Walk-south/Walk-south_000.png + atlas/Walk-south.png + targets//... +``` + +`meta.json` 的坐标原点在左上角,y 轴向下。`anchor` 是 0-1 归一化坐标,`foot_y` 是从画布顶部开始计算的像素值。 + +## 为什么缺一帧就全部失败 + +`expectedFrameCount` 表示后端声明的完整帧数,不能用 `frames.length` 自己推算。两者不同、图片读取失败、PNG 无透明信息、尺寸不一致或 `qualityStatus` 不是 `passed` 时,导出立即失败,不会用透明占位掩盖问题,也不会下载残缺包。 + +## Cocos Creator 边界 + +Issue #94 要求先用真实 Cocos Creator 3.x 验证图集切分数据、`.anim`、`.meta`、UUID 与小版本差异。当前仓库没有该实测结论,因此本模块只落地已确定的通用层和 target 扩展接口,不伪造 Cocos 原生文件。 + +Cocos 坐标转换规则已经明确:通用锚点 `(x, y)` 转成 Creator 锚点 `(x, 1-y)`。等实测字段回填后,只需新增一个 `AssetExportTarget`,不改 `meta.json` 与通用打包逻辑。 + +## 验证 + +```bash +npm test -- src/features/export-package +npm run typecheck +npm run lint +npm run build +``` + +测试覆盖 Schema 校验、连续命名、GIF/图集输出、缺帧失败、质量门禁、图片释放和空 target 扩展。 diff --git a/frontend/src/features/export-package/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts new file mode 100644 index 0000000..225ca58 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -0,0 +1,273 @@ +/** @vitest-environment jsdom */ +import Ajv2020 from 'ajv/dist/2020.js' +import { describe, expect, it, vi } from 'vitest' + +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportRuntime, + type AssetExportTarget, +} from './asset-export' +import { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +import { EXPORT_PACKAGE_JSON_SCHEMA_TEXT } from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel } from './model' + +function frame(index: number): ExportFrame { + return { + imageUrl: `/frames/walk-${index}.png`, + durationMs: 100, + rootMotion: { dx: index, dy: 0 }, + keyFrame: index === 0, + } +} + +function action(frameCount = 9): ExportAction { + return { + id: 'walk-abcdef12', + name: 'Walk / Forward', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + expectedFrameCount: frameCount, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + footY: 36, + qualityStatus: 'passed', + frames: Array.from({ length: frameCount }, (_, index) => frame(index)), + }, + ], + } +} + +const model: ExportPackageModel = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + actions: [action()], +} + +/** 构造足够让契约检查识别为 RGBA PNG 的文件头,解码由测试运行时接管。 */ +function rgbaPng(): Blob { + const data = new Uint8Array(33) + data.set([137, 80, 78, 71, 13, 10, 26, 10], 0) + new DataView(data.buffer).setUint32(8, 13, false) + data.set([73, 72, 68, 82], 12) + data[25] = 6 + return new Blob([data], { type: 'image/png' }) +} + +async function readStoredZip(blob: Blob): Promise> { + const data = new Uint8Array(await blob.arrayBuffer()) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + const decoder = new TextDecoder() + const entries = new Map() + let offset = 0 + + while (offset + 4 <= data.length && view.getUint32(offset, true) === 0x04034b50) { + const compressedSize = view.getUint32(offset + 18, true) + const nameLength = view.getUint16(offset + 26, true) + const extraLength = view.getUint16(offset + 28, true) + const nameStart = offset + 30 + const dataStart = nameStart + nameLength + extraLength + const name = decoder.decode(data.slice(nameStart, nameStart + nameLength)) + entries.set(name, data.slice(dataStart, dataStart + compressedSize)) + offset = dataStart + compressedSize + } + return entries +} + +function runtime(failingUrl: string | null = null): AssetExportRuntime { + return { + fetchFrame: vi.fn(async (url) => { + if (url === failingUrl) throw new Error('missing') + return rgbaPng() + }), + decodeFrame: vi.fn(async () => ({ + source: {} as CanvasImageSource, + width: 32, + height: 40, + close: vi.fn(), + })), + createCanvas: vi.fn((width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + Object.defineProperty(canvas, 'getContext', { + value: () => ({ + clearRect: vi.fn(), + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(width * height * 4) })), + }), + }) + Object.defineProperty(canvas, 'toBlob', { + value: (callback: BlobCallback) => callback(new Blob(['atlas'], { type: 'image/png' })), + }) + return canvas + }), + } +} + +describe('asset export', () => { + it('明确 Cocos 尚未就绪,并只落地已确认的锚点坐标转换', () => { + expect(COCOS_TARGET_READINESS.ready).toBe(false) + const anchor = toCocosAnchor({ x: 0.5, y: 0.9 }) + expect(anchor.x).toBe(0.5) + expect(anchor.y).toBeCloseTo(0.1) + }) + + it('按动作名和方向生成连续三位帧名,并按八列排列图集', () => { + const plan = createAssetExportPlan(model) + + expect(plan).toHaveLength(1) + expect(plan[0]).toMatchObject({ + exportName: 'Walk-Forward-south', + framesFolder: 'frames/Walk-Forward-south', + atlasFile: 'atlas/Walk-Forward-south.png', + previewFile: 'preview/Walk-Forward-south.gif', + columns: 8, + rows: 2, + }) + expect(plan[0]?.frames[0]?.filename).toBe('Walk-Forward-south_000.png') + expect(plan[0]?.frames[8]?.filename).toBe('Walk-Forward-south_008.png') + }) + + it('生成通用目录、透明 PNG、图集、GIF、README、Schema 和可校验的 meta.json', async () => { + const phases: string[] = [] + const result = await exportGameAssets(model, { + runtime: runtime(), + onPhase: (phase) => phases.push(phase), + }) + const entries = await readStoredZip(result.blob) + const root = 'Aster-character-1' + const names = [...entries.keys()] + + expect(result.filename).toBe('windup-Aster-character-1.zip') + expect(phases).toEqual(['validating', 'collecting', 'rendering', 'packing']) + expect(names).toContain(`${root}/meta.json`) + expect(names).toContain(`${root}/schema.json`) + expect(names).toContain(`${root}/README.md`) + expect(names).toContain(`${root}/atlas/Walk-Forward-south.png`) + expect(names).toContain(`${root}/preview/Walk-Forward-south.gif`) + expect(names.filter((name) => name.includes('/frames/'))).toHaveLength(9) + + const meta = JSON.parse(new TextDecoder().decode(entries.get(`${root}/meta.json`))) + const schema = JSON.parse(EXPORT_PACKAGE_JSON_SCHEMA_TEXT) + const validate = new Ajv2020().compile(schema) + expect(validate(meta), JSON.stringify(validate.errors)).toBe(true) + expect(meta).toMatchObject({ + schema_version: '1.0.0', + character: { id: 'character-1', name: 'Aster' }, + canvas: { w: 32, h: 40 }, + source: { workflow_run_id: 'run-1', generation_ids: ['generation-1'] }, + }) + expect(meta.actions[0]).toMatchObject({ + name: 'Walk-Forward-south', + fps: 10, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + foot_y: 36, + atlas: { cols: 8, rows: 2, cell: { w: 32, h: 40 } }, + }) + expect( + new TextDecoder().decode(entries.get(`${root}/preview/Walk-Forward-south.gif`)).slice(0, 6), + ).toBe('GIF89a') + }) + + it('声明帧数与实际帧数不一致时,在读取图片前拒绝导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, expectedFrameCount: 10 }], + }, + ], + } + const testRuntime = runtime() + + await expect(exportGameAssets(badModel, { runtime: testRuntime })).rejects.toThrow( + 'actions[0].sequences[0].frames: 缺帧,期望 10 帧,实际 9 帧', + ) + expect(testRuntime.fetchFrame).not.toHaveBeenCalled() + }) + + it('任一原图读取失败时拒绝整个导出,不再生成透明占位包', async () => { + await expect( + exportGameAssets(model, { runtime: runtime('/frames/walk-4.png') }), + ).rejects.toThrow('frames/Walk-Forward-south/Walk-Forward-south_004.png: 图片读取失败') + }) + + it('质量状态未通过时禁止导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, qualityStatus: 'pending' }], + }, + ], + } + + await expect(exportGameAssets(badModel, { runtime: runtime() })).rejects.toThrow( + 'actions[0].sequences[0].qualityStatus: 质量检测未通过,禁止导出', + ) + }) + + it('空 target 不改变通用层,新增 target 文件只进入自己的目录', async () => { + const emptyTarget: AssetExportTarget = { + id: 'empty', + createFiles: vi.fn(async () => []), + } + const cocosProbe: AssetExportTarget = { + id: 'cocos-probe', + createFiles: vi.fn(async ({ metadata }) => [ + { path: 'anchor-map.json', data: JSON.stringify({ source: metadata.actions[0]?.anchor }) }, + ]), + } + const common = await readStoredZip((await exportGameAssets(model, { runtime: runtime() })).blob) + const extended = await readStoredZip( + (await exportGameAssets(model, { runtime: runtime(), targets: [emptyTarget, cocosProbe] })) + .blob, + ) + const targetPath = 'Aster-character-1/targets/cocos-probe/anchor-map.json' + + expect([...extended.keys()].filter((name) => !name.includes('/targets/'))).toEqual([ + ...common.keys(), + ]) + expect(extended.has(targetPath)).toBe(true) + expect(emptyTarget.createFiles).toHaveBeenCalledTimes(1) + }) + + it('渲染失败时释放已经解码的全部图片', async () => { + const baseRuntime = runtime() + const closes: Array> = [] + const failingRuntime: AssetExportRuntime = { + ...baseRuntime, + decodeFrame: vi.fn(async () => { + const close = vi.fn() + closes.push(close) + return { source: {} as CanvasImageSource, width: 32, height: 40, close } + }), + createCanvas: vi.fn((width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + Object.defineProperty(canvas, 'getContext', { value: () => null }) + return canvas + }), + } + + await expect(exportGameAssets(model, { runtime: failingRuntime })).rejects.toThrow( + 'atlas/Walk-Forward-south.png: 浏览器无法创建 2D 画布', + ) + expect(closes).toHaveLength(9) + expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true) + }) +}) diff --git a/frontend/src/features/export-package/asset-export.ts b/frontend/src/features/export-package/asset-export.ts new file mode 100644 index 0000000..51532a2 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.ts @@ -0,0 +1,573 @@ +import { GIFEncoder, applyPalette, quantize } from 'gifenc' + +import { + EXPORT_PACKAGE_JSON_SCHEMA_TEXT, + EXPORT_PACKAGE_SCHEMA_VERSION, + type GenericExportMetadata, + validateExportPackageModel, +} from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel, ExportSequence } from './model' + +export type AssetExportPhase = 'validating' | 'collecting' | 'rendering' | 'packing' + +export interface AssetExportResult { + blob: Blob + filename: string +} + +export interface DecodedFrame { + source: CanvasImageSource + width: number + height: number + close(): void +} + +export interface AssetExportRuntime { + fetchFrame(url: string): Promise + decodeFrame(blob: Blob): Promise + createCanvas(width: number, height: number): HTMLCanvasElement +} + +export interface PlannedFrame { + frame: ExportFrame + index: number + filename: string + relativeFile: string +} + +export interface PlannedSequence { + action: ExportAction + sequence: ExportSequence + exportName: string + framesFolder: string + atlasFile: string + previewFile: string + columns: number + rows: number + frames: readonly PlannedFrame[] +} + +export interface AssetExportTargetFile { + /** 相对于 targets// 的路径。 */ + path: string + data: Blob | string | Uint8Array +} + +export interface AssetExportTargetContext { + model: ExportPackageModel + metadata: GenericExportMetadata + plan: readonly PlannedSequence[] +} + +/** 新引擎只实现 target,不应修改通用 meta.json、frames、atlas 与 preview。 */ +export interface AssetExportTarget { + id: string + createFiles(context: AssetExportTargetContext): Promise +} + +export interface ExportGameAssetsOptions { + runtime?: AssetExportRuntime + targets?: readonly AssetExportTarget[] + onPhase?: (phase: AssetExportPhase) => void +} + +interface LoadedFrame extends PlannedFrame { + data: Uint8Array + decoded: DecodedFrame +} + +interface LoadedSequence { + item: PlannedSequence + frames: readonly LoadedFrame[] +} + +interface ZipEntry { + name: string + data: Uint8Array +} + +function safeSegment(value: string, fallback: string): string { + const normalized = value + .normalize('NFKC') + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + return normalized || fallback +} + +function idSuffix(id: string): string { + return safeSegment(id, 'id').slice(-8) || 'id' +} + +function packageRoot(model: ExportPackageModel): string { + return `${safeSegment(model.characterName, 'character')}-${safeSegment(model.characterId, 'id')}` +} + +function uniqueActionName( + action: ExportAction, + sequence: ExportSequence, + usedNames: Set, +): string { + const baseName = safeSegment(action.name, 'action') + const direction = safeSegment(sequence.direction, 'default') + const candidate = direction === 'default' ? baseName : `${baseName}-${direction}` + const unique = usedNames.has(candidate) ? `${candidate}-${idSuffix(action.id)}` : candidate + if (usedNames.has(unique)) throw new Error(`actions.name: 导出动作名重复:${unique}`) + usedNames.add(unique) + return unique +} + +export function createAssetExportPlan(model: ExportPackageModel): readonly PlannedSequence[] { + const usedNames = new Set() + return model.actions.flatMap((action) => + action.sequences.flatMap((sequence) => { + if (sequence.frames.length === 0) return [] + const exportName = uniqueActionName(action, sequence, usedNames) + const columns = Math.min(8, sequence.frames.length) + return [ + { + action, + sequence, + exportName, + framesFolder: `frames/${exportName}`, + atlasFile: `atlas/${exportName}.png`, + previewFile: `preview/${exportName}.gif`, + columns, + rows: Math.ceil(sequence.frames.length / columns), + frames: sequence.frames.map((currentFrame, index) => { + const filename = `${exportName}_${String(index).padStart(3, '0')}.png` + return { + frame: currentFrame, + index, + filename, + relativeFile: `frames/${exportName}/${filename}`, + } + }), + }, + ] + }), + ) +} + +const defaultRuntime: AssetExportRuntime = { + async fetchFrame(url) { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.blob() + }, + async decodeFrame(blob) { + const bitmap = await createImageBitmap(blob) + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + close: () => bitmap.close(), + } + }, + createCanvas(width, height) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas + }, +} + +function canvasPng(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob === null) reject(new Error('atlas: PNG 编码失败')) + else resolve(blob) + }, 'image/png') + }) +} + +async function bytes(data: Blob | string | Uint8Array): Promise { + if (typeof data === 'string') return new TextEncoder().encode(data) + if (data instanceof Uint8Array) return data + return new Uint8Array(await data.arrayBuffer()) +} + +function hasPngSignature(data: Uint8Array): boolean { + const signature = [137, 80, 78, 71, 13, 10, 26, 10] + return signature.every((value, index) => data[index] === value) +} + +/** + * MIME 只能说明“服务端声称是 PNG”,不能证明文件真的可用或带透明信息。 + * 因此这里读取 PNG 头,并兼容 RGBA、灰度 Alpha 与带 tRNS 块的索引 PNG。 + */ +function assertPngWithAlpha(data: Uint8Array, field: string): void { + if (data.length < 33 || !hasPngSignature(data)) throw new Error(`${field}: 必须是有效 PNG`) + const colorType = data[25] + if (colorType === 4 || colorType === 6) return + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + let offset = 8 + while (offset + 12 <= data.length) { + const chunkLength = view.getUint32(offset, false) + const chunkEnd = offset + 12 + chunkLength + if (chunkEnd > data.length) break + const chunkName = String.fromCharCode(...data.slice(offset + 4, offset + 8)) + if (chunkName === 'tRNS') return + if (chunkName === 'IEND') break + offset = chunkEnd + } + throw new Error(`${field}: PNG 必须包含 Alpha 透明通道`) +} + +async function loadFrame( + planned: PlannedFrame, + runtime: AssetExportRuntime, + cache: Map>, +): Promise { + const field = `${planned.relativeFile}` + let pending = cache.get(planned.frame.imageUrl) + if (pending === undefined) { + pending = runtime.fetchFrame(planned.frame.imageUrl) + cache.set(planned.frame.imageUrl, pending) + } + + let blob: Blob + try { + blob = await pending + } catch (error) { + const reason = error instanceof Error ? error.message : '未知错误' + throw new Error(`${field}: 图片读取失败(${reason})`) + } + if (blob.type.toLowerCase() !== 'image/png') throw new Error(`${field}: 文件类型必须是 image/png`) + const data = await bytes(blob) + assertPngWithAlpha(data, field) + + let decoded: DecodedFrame + try { + decoded = await runtime.decodeFrame(blob) + } catch (error) { + const reason = error instanceof Error ? error.message : '未知错误' + throw new Error(`${field}: PNG 解码失败(${reason})`) + } + return { ...planned, data, decoded } +} + +async function loadAllFrames( + plan: readonly PlannedSequence[], + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const cache = new Map>() + const references = plan.flatMap((item) => item.frames.map((frame) => ({ item, frame }))) + const settled = await Promise.allSettled( + references.map(async ({ item, frame }) => { + const loaded = await loadFrame(frame, runtime, cache) + if ( + loaded.decoded.width !== model.canvas.width || + loaded.decoded.height !== model.canvas.height + ) { + loaded.decoded.close() + throw new Error( + `${frame.relativeFile}: 画布应为 ${model.canvas.width}x${model.canvas.height},实际为 ${loaded.decoded.width}x${loaded.decoded.height}`, + ) + } + return { item, loaded } + }), + ) + + const fulfilled = settled.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ) + const failure = settled.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') { + fulfilled.forEach(({ loaded }) => loaded.decoded.close()) + throw failure.reason + } + + return plan.map((item) => ({ + item, + frames: fulfilled.filter((result) => result.item === item).map((result) => result.loaded), + })) +} + +function context2d(canvas: HTMLCanvasElement, field: string): CanvasRenderingContext2D { + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (context === null) throw new Error(`${field}: 浏览器无法创建 2D 画布`) + return context +} + +async function renderAtlas( + loaded: LoadedSequence, + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const canvas = runtime.createCanvas( + model.canvas.width * loaded.item.columns, + model.canvas.height * loaded.item.rows, + ) + const context = context2d(canvas, loaded.item.atlasFile) + context.clearRect(0, 0, canvas.width, canvas.height) + loaded.frames.forEach((frame) => { + const column = frame.index % loaded.item.columns + const row = Math.floor(frame.index / loaded.item.columns) + context.drawImage(frame.decoded.source, column * model.canvas.width, row * model.canvas.height) + }) + return canvasPng(canvas) +} + +function renderGif( + loaded: LoadedSequence, + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Blob { + const canvas = runtime.createCanvas(model.canvas.width, model.canvas.height) + const context = context2d(canvas, loaded.item.previewFile) + const gif = GIFEncoder() + + loaded.frames.forEach((frame) => { + context.clearRect(0, 0, canvas.width, canvas.height) + context.drawImage(frame.decoded.source, 0, 0) + const rgba = context.getImageData(0, 0, canvas.width, canvas.height).data + const palette = quantize(rgba, 256, { format: 'rgba4444', oneBitAlpha: true }) + const indexed = applyPalette(rgba, palette, 'rgba4444') + const transparentIndex = palette.findIndex((color) => color[3] === 0) + gif.writeFrame(indexed, canvas.width, canvas.height, { + palette, + delay: frame.frame.durationMs, + repeat: loaded.item.sequence.loop ? 0 : -1, + transparent: transparentIndex >= 0, + transparentIndex: Math.max(0, transparentIndex), + dispose: 2, + }) + }) + gif.finish() + const output = gif.bytes() + const buffer = new ArrayBuffer(output.length) + new Uint8Array(buffer).set(output) + return new Blob([buffer], { type: 'image/gif' }) +} + +function createMetadata( + model: ExportPackageModel, + plan: readonly PlannedSequence[], +): GenericExportMetadata { + return { + schema_version: EXPORT_PACKAGE_SCHEMA_VERSION, + character: { id: model.characterId, name: model.characterName }, + canvas: { w: model.canvas.width, h: model.canvas.height }, + actions: plan.map((item) => ({ + name: item.exportName, + fps: item.action.fps, + loop: item.sequence.loop, + frames: item.frames.map((frame) => ({ index: frame.index, file: frame.filename })), + anchor: { ...item.sequence.anchor }, + foot_y: item.sequence.footY, + atlas: { + file: item.atlasFile, + cols: item.columns, + rows: item.rows, + cell: { w: model.canvas.width, h: model.canvas.height }, + }, + preview: item.previewFile, + })), + source: { + workflow_run_id: model.source.workflowRunId, + generation_ids: [...model.source.generationIds], + }, + } +} + +function createReadme(model: ExportPackageModel): string { + return `# ${model.characterName} 导出包 + +这是 Windup 通用资产包,契约版本为 ${EXPORT_PACKAGE_SCHEMA_VERSION}。 + +## 内容 + +- \`meta.json\`: 动作、帧率、循环、画布、锚点、脚底线、图集与生成记录。 +- \`frames//\`: 连续编号的透明 PNG 原始帧。 +- \`atlas/.png\`: 按 \`meta.json\` 中 cols、rows 和 cell 切分的图集。 +- \`preview/.gif\`: 动作预览。 +- \`schema.json\`: 校验 \`meta.json\` 的 JSON Schema。 +- \`targets//\`: 可选引擎适配器产生的原生文件。 + +## 坐标 + +通用层原点在画布左上角,y 轴向下;anchor 的 x/y 都是 0 到 1。 +引擎 target 负责坐标换算。例如 Cocos Creator 使用左下角原点,需要换算为 (x, 1-y)。 + +## Cocos Creator 状态 + +本包没有伪造 .anim 或 .meta。Issue #94 要求先在真实 Creator 3.x 中确认图集切分、UUID 和版本格式; +验证完成前只能使用通用 frames、atlas 和 meta.json,不能声称“拖入即播放”。 +` +} + +function safeTargetPath(targetId: string, path: string, index: number): string { + const normalized = path.replace(/\\/g, '/') + if ( + normalized.length === 0 || + normalized.startsWith('/') || + normalized.split('/').some((segment) => segment === '..' || segment === '') + ) { + throw new Error(`targets.${targetId}.files[${index}].path: 必须是安全的相对路径`) + } + return `targets/${safeSegment(targetId, 'target')}/${normalized}` +} + +function uint32Table(): Uint32Array { + const table = new Uint32Array(256) + for (let value = 0; value < 256; value += 1) { + let current = value + for (let bit = 0; bit < 8; bit += 1) { + current = (current & 1) !== 0 ? 0xedb88320 ^ (current >>> 1) : current >>> 1 + } + table[value] = current >>> 0 + } + return table +} + +const CRC32_TABLE = uint32Table() + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff + for (const value of data) crc = (crc >>> 8) ^ (CRC32_TABLE[(crc ^ value) & 0xff] ?? 0) + return (crc ^ 0xffffffff) >>> 0 +} + +function dosDateTime(date: Date): { date: number; time: number } { + const year = Math.max(1980, date.getFullYear()) + return { + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + } +} + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)) + let offset = 0 + for (const chunk of chunks) { + output.set(chunk, offset) + offset += chunk.length + } + return output +} + +function storedZip(entries: readonly ZipEntry[]): Blob { + const localChunks: Uint8Array[] = [] + const centralChunks: Uint8Array[] = [] + const encoder = new TextEncoder() + const timestamp = dosDateTime(new Date()) + let localOffset = 0 + + for (const entry of entries) { + const name = encoder.encode(entry.name) + const checksum = crc32(entry.data) + const local = new Uint8Array(30 + name.length) + const localView = new DataView(local.buffer) + localView.setUint32(0, 0x04034b50, true) + localView.setUint16(4, 20, true) + localView.setUint16(6, 0x0800, true) + localView.setUint16(8, 0, true) + localView.setUint16(10, timestamp.time, true) + localView.setUint16(12, timestamp.date, true) + localView.setUint32(14, checksum, true) + localView.setUint32(18, entry.data.length, true) + localView.setUint32(22, entry.data.length, true) + localView.setUint16(26, name.length, true) + local.set(name, 30) + localChunks.push(local, entry.data) + + const central = new Uint8Array(46 + name.length) + const centralView = new DataView(central.buffer) + centralView.setUint32(0, 0x02014b50, true) + centralView.setUint16(4, 20, true) + centralView.setUint16(6, 20, true) + centralView.setUint16(8, 0x0800, true) + centralView.setUint16(10, 0, true) + centralView.setUint16(12, timestamp.time, true) + centralView.setUint16(14, timestamp.date, true) + centralView.setUint32(16, checksum, true) + centralView.setUint32(20, entry.data.length, true) + centralView.setUint32(24, entry.data.length, true) + centralView.setUint16(28, name.length, true) + centralView.setUint32(42, localOffset, true) + central.set(name, 46) + centralChunks.push(central) + localOffset += local.length + entry.data.length + } + + const centralDirectory = concat(centralChunks) + const end = new Uint8Array(22) + const endView = new DataView(end.buffer) + endView.setUint32(0, 0x06054b50, true) + endView.setUint16(8, entries.length, true) + endView.setUint16(10, entries.length, true) + endView.setUint32(12, centralDirectory.length, true) + endView.setUint32(16, localOffset, true) + const output = concat([...localChunks, centralDirectory, end]) + const buffer = new ArrayBuffer(output.length) + new Uint8Array(buffer).set(output) + return new Blob([buffer], { type: 'application/zip' }) +} + +export async function exportGameAssets( + model: ExportPackageModel, + options: ExportGameAssetsOptions = {}, +): Promise { + const runtime = options.runtime ?? defaultRuntime + options.onPhase?.('validating') + validateExportPackageModel(model) + const plan = createAssetExportPlan(model) + const metadata = createMetadata(model, plan) + + options.onPhase?.('collecting') + const loaded = await loadAllFrames(plan, model, runtime) + const root = packageRoot(model) + const entries: ZipEntry[] = [] + + try { + options.onPhase?.('rendering') + for (const current of loaded) { + for (const frame of current.frames) { + entries.push({ name: `${root}/${frame.relativeFile}`, data: frame.data }) + } + entries.push({ + name: `${root}/${current.item.atlasFile}`, + data: await bytes(await renderAtlas(current, model, runtime)), + }) + entries.push({ + name: `${root}/${current.item.previewFile}`, + data: await bytes(renderGif(current, model, runtime)), + }) + } + + entries.push( + { + name: `${root}/meta.json`, + data: await bytes(JSON.stringify(metadata, null, 2)), + }, + { name: `${root}/schema.json`, data: await bytes(EXPORT_PACKAGE_JSON_SCHEMA_TEXT) }, + { name: `${root}/README.md`, data: await bytes(createReadme(model)) }, + ) + + for (const target of options.targets ?? []) { + const targetId = safeSegment(target.id, 'target') + const files = await target.createFiles({ model, metadata, plan }) + for (const [index, file] of files.entries()) { + entries.push({ + name: `${root}/${safeTargetPath(targetId, file.path, index)}`, + data: await bytes(file.data), + }) + } + } + } finally { + loaded.forEach(({ frames }) => frames.forEach((frame) => frame.decoded.close())) + } + + const duplicate = entries.find( + (entry, index) => entries.findIndex((item) => item.name === entry.name) !== index, + ) + if (duplicate !== undefined) throw new Error(`package.files: 文件路径重复:${duplicate.name}`) + + options.onPhase?.('packing') + return { + blob: storedZip(entries), + filename: `windup-${root}.zip`, + } +} diff --git a/frontend/src/features/export-package/cocos-target.ts b/frontend/src/features/export-package/cocos-target.ts new file mode 100644 index 0000000..5085f2a --- /dev/null +++ b/frontend/src/features/export-package/cocos-target.ts @@ -0,0 +1,15 @@ +import type { ExportAnchor } from './model' + +/** + * Issue #94 的 Cocos 原生文件格式仍等待真实 Creator 3.x 实测。 + * 这里公开状态,避免页面或调用方把通用包误标成“Cocos 拖入即用包”。 + */ +export const COCOS_TARGET_READINESS = { + ready: false, + reason: '等待真实 Cocos Creator 3.x 验证 .anim、.meta、UUID 与图集切分格式', +} as const + +/** 通用层左上原点转为 Cocos Creator 左下原点;x 不变,y 上下翻转。 */ +export function toCocosAnchor(anchor: ExportAnchor): ExportAnchor { + return { x: anchor.x, y: 1 - anchor.y } +} diff --git a/frontend/src/features/export-package/contract.ts b/frontend/src/features/export-package/contract.ts new file mode 100644 index 0000000..b7d5378 --- /dev/null +++ b/frontend/src/features/export-package/contract.ts @@ -0,0 +1,116 @@ +import exportSchemaText from './export-package.schema.json?raw' +import type { ExportPackageModel } from './model' + +export const EXPORT_PACKAGE_SCHEMA_VERSION = '1.0.0' +export const EXPORT_PACKAGE_JSON_SCHEMA_TEXT = exportSchemaText + +export interface GenericExportFrame { + index: number + file: string +} + +export interface GenericExportAction { + name: string + fps: number + loop: boolean + frames: readonly GenericExportFrame[] + anchor: { x: number; y: number } + foot_y: number + atlas: { + file: string + cols: number + rows: number + cell: { w: number; h: number } + } + preview: string +} + +export interface GenericExportMetadata { + schema_version: typeof EXPORT_PACKAGE_SCHEMA_VERSION + character: { id: string; name: string } + canvas: { w: number; h: number } + actions: readonly GenericExportAction[] + source: { + workflow_run_id: string + generation_ids: readonly string[] + } +} + +function fail(field: string, reason: string): never { + throw new Error(`${field}: ${reason}`) +} + +function requireText(field: string, value: string): void { + if (typeof value !== 'string' || value.trim().length === 0) fail(field, '必须是非空字符串') +} + +function requirePositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) fail(field, '必须是大于 0 的整数') +} + +function requireUnitNumber(field: string, value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) fail(field, '必须是 0 到 1 的数值') +} + +/** + * 在读取任何图片前完成结构与质量门禁。 + * 报错路径使用 meta.json 对应字段名,方便调用方直接定位坏数据。 + */ +export function validateExportPackageModel(model: ExportPackageModel): void { + requireText('character.id', model.characterId) + requireText('character.name', model.characterName) + requireText('outfit.id', model.outfitId) + requireText('outfit.name', model.outfitName) + requirePositiveInteger('canvas.w', model.canvas.width) + requirePositiveInteger('canvas.h', model.canvas.height) + requireText('source.workflow_run_id', model.source.workflowRunId) + + if (model.source.generationIds.length === 0) { + fail('source.generation_ids', '至少需要一条生成记录') + } + const generationIds = new Set() + model.source.generationIds.forEach((id, index) => { + requireText(`source.generation_ids[${index}]`, id) + if (generationIds.has(id)) fail(`source.generation_ids[${index}]`, '生成记录不能重复') + generationIds.add(id) + }) + + if (model.actions.length === 0) fail('actions', '至少需要一个动作') + model.actions.forEach((action, actionIndex) => { + const actionField = `actions[${actionIndex}]` + requireText(`${actionField}.name`, action.name) + requirePositiveInteger(`${actionField}.fps`, action.fps) + if (action.sequences.length === 0) fail(`${actionField}.sequences`, '至少需要一个动作方向') + + action.sequences.forEach((sequence, sequenceIndex) => { + const sequenceField = `${actionField}.sequences[${sequenceIndex}]` + requireText(`${sequenceField}.direction`, sequence.direction) + requirePositiveInteger(`${sequenceField}.expectedFrameCount`, sequence.expectedFrameCount) + requireUnitNumber(`${sequenceField}.anchor.x`, sequence.anchor.x) + requireUnitNumber(`${sequenceField}.anchor.y`, sequence.anchor.y) + if ( + !Number.isInteger(sequence.footY) || + sequence.footY < 0 || + sequence.footY > model.canvas.height + ) { + fail(`${sequenceField}.footY`, `必须是 0 到 ${model.canvas.height} 的整数像素值`) + } + if (sequence.qualityStatus !== 'passed') { + fail(`${sequenceField}.qualityStatus`, '质量检测未通过,禁止导出') + } + if (sequence.frames.length !== sequence.expectedFrameCount) { + fail( + `${sequenceField}.frames`, + `缺帧,期望 ${sequence.expectedFrameCount} 帧,实际 ${sequence.frames.length} 帧`, + ) + } + sequence.frames.forEach((frame, frameIndex) => { + requireText(`${sequenceField}.frames[${frameIndex}].imageUrl`, frame.imageUrl) + requirePositiveInteger( + `${sequenceField}.frames[${frameIndex}].durationMs`, + frame.durationMs, + ) + }) + }) + }) +} diff --git a/frontend/src/features/export-package/export-package.schema.json b/frontend/src/features/export-package/export-package.schema.json new file mode 100644 index 0000000..fb75d07 --- /dev/null +++ b/frontend/src/features/export-package/export-package.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://windup.local/schemas/export-package-1.0.0.json", + "title": "Windup Generic Export Package", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "character", "canvas", "actions", "source"], + "properties": { + "schema_version": { "const": "1.0.0" }, + "character": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 } + } + }, + "canvas": { + "type": "object", + "additionalProperties": false, + "required": ["w", "h"], + "properties": { + "w": { "type": "integer", "minimum": 1 }, + "h": { "type": "integer", "minimum": 1 } + } + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "fps", "loop", "frames", "anchor", "foot_y", "atlas", "preview"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "fps": { "type": "integer", "minimum": 1 }, + "loop": { "type": "boolean" }, + "frames": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["index", "file"], + "properties": { + "index": { "type": "integer", "minimum": 0 }, + "file": { "type": "string", "pattern": "^[^/]+_[0-9]{3}\\.png$" } + } + } + }, + "anchor": { + "type": "object", + "additionalProperties": false, + "required": ["x", "y"], + "properties": { + "x": { "type": "number", "minimum": 0, "maximum": 1 }, + "y": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "foot_y": { "type": "integer", "minimum": 0 }, + "atlas": { + "type": "object", + "additionalProperties": false, + "required": ["file", "cols", "rows", "cell"], + "properties": { + "file": { "type": "string", "pattern": "^atlas/.+\\.png$" }, + "cols": { "type": "integer", "minimum": 1 }, + "rows": { "type": "integer", "minimum": 1 }, + "cell": { + "type": "object", + "additionalProperties": false, + "required": ["w", "h"], + "properties": { + "w": { "type": "integer", "minimum": 1 }, + "h": { "type": "integer", "minimum": 1 } + } + } + } + }, + "preview": { "type": "string", "pattern": "^preview/.+\\.gif$" } + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["workflow_run_id", "generation_ids"], + "properties": { + "workflow_run_id": { "type": "string", "minLength": 1 }, + "generation_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/frontend/src/features/export-package/export-panel.test.tsx b/frontend/src/features/export-package/export-panel.test.tsx new file mode 100644 index 0000000..fe37af0 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -0,0 +1,121 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ExportPackageModel } from './model' +import { ExportPanel } from './export-panel' + +const model = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + actions: [ + { + id: 'walk-abcdef12', + name: 'Walk', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + expectedFrameCount: 1, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + footY: 36, + qualityStatus: 'passed', + frames: [ + { + imageUrl: '/walk.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + ], +} satisfies ExportPackageModel + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ExportPanel', () => { + it('质量问题会阻止导出,而不是只显示警告', () => { + render() + + expect(screen.getByText('当前有 3 项质量问题,全部通过后才能导出')).toBeTruthy() + expect( + (screen.getByRole('button', { name: '导出游戏资产包' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('显示进度、阻止重复点击、下载后释放临时地址', async () => { + let resolveExport: (value: { blob: Blob; filename: string }) => void = () => { + throw new Error('export promise was not initialized') + } + const exporter = vi.fn( + ( + _model: ExportPackageModel, + onPhase?: (phase: 'validating' | 'collecting' | 'rendering' | 'packing') => void, + ) => { + onPhase?.('rendering') + return new Promise<{ blob: Blob; filename: string }>((resolve) => { + resolveExport = resolve + }) + }, + ) + const createObjectURL = vi.fn(() => 'blob:asset-package') + const revokeObjectURL = vi.fn() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL }) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + const button = screen.getByRole('button', { name: '导出游戏资产包' }) + fireEvent.click(button) + fireEvent.click(button) + expect(screen.getByText('正在生成图片')).toBeTruthy() + expect(exporter).toHaveBeenCalledTimes(1) + + resolveExport({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-character-1.zip', + }) + await waitFor(() => expect(screen.getByText('下载完成')).toBeTruthy()) + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:asset-package') + click.mockRestore() + }) + + it('展示具体错误字段,并允许修复后重试', async () => { + const exporter = vi + .fn() + .mockRejectedValueOnce(new Error('actions[0].frames: 缺帧')) + .mockResolvedValueOnce({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-character-1.zip', + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:retry'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '导出游戏资产包' })) + await waitFor(() => expect(screen.getByText('导出失败:actions[0].frames: 缺帧')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: '重新导出' })) + await waitFor(() => expect(screen.getByText('下载完成')).toBeTruthy()) + expect(exporter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/features/export-package/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx new file mode 100644 index 0000000..76be6b0 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react' + +import type { ExportPackageModel } from './model' +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportPhase, + type AssetExportResult, +} from './asset-export' + +export type AssetExporter = ( + model: ExportPackageModel, + onPhase?: (phase: AssetExportPhase) => void, +) => Promise + +export interface ExportPanelProps { + model: ExportPackageModel + qualityIssueCount?: number + exporter?: AssetExporter +} + +type ExportState = + | { status: 'idle' } + | { status: 'working'; phase: AssetExportPhase } + | { status: 'success' } + | { status: 'failure'; message: string } + +const PHASE_LABELS: Readonly> = { + validating: '正在检查导出条件', + collecting: '正在整理素材', + rendering: '正在生成图片', + packing: '正在打包', +} + +const defaultExporter: AssetExporter = (model, onPhase) => exportGameAssets(model, { onPhase }) + +export function ExportPanel({ + model, + qualityIssueCount = 0, + exporter = defaultExporter, +}: ExportPanelProps) { + const [state, setState] = useState({ status: 'idle' }) + const plan = createAssetExportPlan(model) + const working = state.status === 'working' + + const startExport = async () => { + if (working) return + setState({ status: 'working', phase: 'validating' }) + try { + const result = await exporter(model, (phase) => setState({ status: 'working', phase })) + const url = URL.createObjectURL(result.blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = result.filename + anchor.click() + URL.revokeObjectURL(url) + setState({ status: 'success' }) + } catch (error) { + setState({ + status: 'failure', + message: error instanceof Error ? error.message : '未知错误', + }) + } + } + + return ( +
+
+

GAME ASSETS

+

资产导出

+

透明 PNG、图集、GIF 与通用元数据

+
+ +
+
动作方向
+
{plan.length} 组
+
逐帧原图
+
+ {plan.reduce((total, item) => total + item.frames.length, 0)} 张 +
+
每行上限
+
8 帧
+
+ + {qualityIssueCount > 0 ? ( +

+ 当前有 {qualityIssueCount} 项质量问题,全部通过后才能导出 +

+ ) : null} + + {state.status === 'working' ? ( +

+ {PHASE_LABELS[state.phase]} +

+ ) : state.status === 'failure' ? ( +

+ 导出失败:{state.message} +

+ ) : state.status === 'success' ? ( +

下载完成

+ ) : null} + + + {plan.length === 0 ?

没有可导出的已确认动作

: null} +
+ ) +} diff --git a/frontend/src/features/export-package/gifenc.d.ts b/frontend/src/features/export-package/gifenc.d.ts new file mode 100644 index 0000000..48467e8 --- /dev/null +++ b/frontend/src/features/export-package/gifenc.d.ts @@ -0,0 +1,33 @@ +declare module 'gifenc' { + export type GifPalette = number[][] + + export interface GifFrameOptions { + palette?: GifPalette + transparent?: boolean + transparentIndex?: number + delay?: number + repeat?: number + dispose?: number + } + + export interface GifEncoderInstance { + writeFrame(index: Uint8Array, width: number, height: number, options?: GifFrameOptions): void + finish(): void + bytes(): Uint8Array + } + + export function GIFEncoder(options?: { + auto?: boolean + initialCapacity?: number + }): GifEncoderInstance + export function quantize( + rgba: Uint8Array | Uint8ClampedArray, + maxColors: number, + options?: { format?: 'rgb565' | 'rgb444' | 'rgba4444'; oneBitAlpha?: boolean | number }, + ): GifPalette + export function applyPalette( + rgba: Uint8Array | Uint8ClampedArray, + palette: GifPalette, + format?: 'rgb565' | 'rgb444' | 'rgba4444', + ): Uint8Array +} diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts new file mode 100644 index 0000000..82f2ab3 --- /dev/null +++ b/frontend/src/features/export-package/index.ts @@ -0,0 +1,29 @@ +/** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ +export { ExportPanel } from './export-panel' +export type { + ExportAction, + ExportAnchor, + ExportFrame, + ExportPackageModel, + ExportQualityStatus, + ExportSequence, + ExportSourceReference, +} from './model' +export { + EXPORT_PACKAGE_JSON_SCHEMA_TEXT, + EXPORT_PACKAGE_SCHEMA_VERSION, + validateExportPackageModel, + type GenericExportMetadata, +} from './contract' +export { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +export { + createAssetExportPlan, + exportGameAssets, + type AssetExportTarget, + type AssetExportTargetContext, + type AssetExportTargetFile, + type AssetExportPhase, + type AssetExportResult, + type AssetExportRuntime, + type ExportGameAssetsOptions, +} from './asset-export' diff --git a/frontend/src/features/export-package/model.ts b/frontend/src/features/export-package/model.ts new file mode 100644 index 0000000..f954a3d --- /dev/null +++ b/frontend/src/features/export-package/model.ts @@ -0,0 +1,63 @@ +import type { ActionType, Frame } from '@/entities' + +/** + * 导出模块只读取这份模型,不直接读取 Playtest 页面状态。 + * 页面或后端适配器负责把当前角色、动作和生成记录整理成该模型。 + */ +export interface ExportFrame { + imageUrl: string + durationMs: number + rootMotion: Frame['rootMotion'] + keyFrame: boolean +} + +/** 通用契约使用左上角为原点、y 轴向下的 0-1 归一化坐标。 */ +export interface ExportAnchor { + x: number + y: number +} + +export type ExportQualityStatus = 'passed' | 'pending' | 'failed' + +export interface ExportSequence { + direction: string + /** 后端声明的完整帧数;不能用 frames.length 代替,否则无法发现缺帧。 */ + expectedFrameCount: number + loop: boolean + anchor: ExportAnchor + /** 脚底线距离画布顶部的像素值。 */ + footY: number + /** 只有 passed 的动作序列可以进入正式导出包。 */ + qualityStatus: ExportQualityStatus + frames: readonly ExportFrame[] +} + +export interface ExportAction { + id: string + name: string + type: ActionType | 'crouch' + fps: number + sequences: readonly ExportSequence[] +} + +export interface ExportSourceReference { + workflowRunId: string + generationIds: readonly string[] +} + +export interface ExportPackageModel { + characterId: string + characterName: string + outfitId: string + outfitName: string + characterTemplateUrl: string | null + baseFrameCount: number + /** 同一导出包内所有帧必须使用相同画布尺寸。 */ + canvas: { + width: number + height: number + } + /** 生成链路引用,用于从导出物追溯到 WorkflowRun 与生成任务。 */ + source: ExportSourceReference + actions: readonly ExportAction[] +}