diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d34eaf8..f55946c6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", @@ -2375,6 +2376,23 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/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", @@ -2709,6 +2727,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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", @@ -2884,6 +2926,13 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/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 1a7e5301..f7a37f8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 8d4a4a77..a3bdebdb 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -6,7 +6,8 @@ import { AccountPage } from '@/pages/account' import { CharacterDetailPage } from '@/pages/character-detail' import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' -import { PlaytestEntryPage, PlaytestPage } from '@/pages/playtest' +import { PlaytestEntryPage } from '@/pages/playtest' +import { PlaytestExportPage } from './playtest-export-page' import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' @@ -55,7 +56,7 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/app/playtest-export-page.tsx b/frontend/src/app/playtest-export-page.tsx new file mode 100644 index 00000000..d4607507 --- /dev/null +++ b/frontend/src/app/playtest-export-page.tsx @@ -0,0 +1,33 @@ +import { createProgressiveExportModel, ExportButton } from '@/features/export-package' +import { PlaytestPage, type PlaytestPageProps } from '@/pages/playtest' + +const renderToolbar: NonNullable = ({ + project, + character, + outfitId, + initialActionId, +}) => { + const outfit = character.outfits.find((candidate) => candidate.id === outfitId) + if (!outfit?.actions.some((action) => action.frames.length > 0)) return null + try { + const model = createProgressiveExportModel({ + project, + character, + outfitId, + playtest: { initialActionId }, + }) + return ( + + ) + } catch { + // 旧资产可以继续试玩;缺少母版时只隐藏无法满足契约的导出入口。 + return null + } +} + +export function PlaytestExportPage() { + return +} diff --git a/frontend/src/features/export-package/README.md b/frontend/src/features/export-package/README.md new file mode 100644 index 00000000..fa3c6598 --- /dev/null +++ b/frontend/src/features/export-package/README.md @@ -0,0 +1,67 @@ +# Export Package 模块 + +本模块把 WorkflowRun 当前已完成的角色素材整理为可下载 ZIP。它不负责生成图片、保存历史记录或发布资产,只负责按完成度验证和导出。 + +## 渐进阶段 + +1. `character`:角色母版、项目画布、Character / Outfit 和来源 Run。 +2. `first-frame`:保留基础包并追加每个已确认动作的首帧与配置。 +3. `action-assets`:继续追加完整帧、逐帧时长、图集和质量状态;尚未审核时为 `pending`。 +4. `playtest`:只使用已发布动作,并追加 `playtest.json` 运行清单。 + +四个阶段使用相同的 `characterId + outfitId` 包根目录。后阶段只追加内容,不另造导出格式。 + +## 数据怎么走 + +1. Quick Start、Workflow Editor、CharacterDetail 或 App 层 Playtest 组合点,用同一个渐进装配器生成 `ExportPackageModel`。 +2. `validateExportPackageModel` 检查角色、画布、生成记录、帧数、质量状态、锚点和脚底线。 +3. `createAssetExportPlan` 为每个动作方向生成稳定目录与三位连续帧名。 +4. `exportGameAssets` 读取透明 PNG,并检查图片尺寸是否与统一画布一致。 +5. 浏览器生成 Sprite Sheet,最后写入动画 `meta.json`、`schema.json`、README 与 ZIP。 +6. 可选 target 只在 `targets//` 下追加引擎文件,不修改通用层。 + +## 导出结构 + +```text +Aster-character-1-Explorer-outfit-1/ + character/master.png + first-frames/Walk-walk.png + meta.json + schema.json + README.md + frames/Walk-south/Walk-south_000.png + atlas/Walk-south.png + playtest.json + targets//... +``` + +`meta.json` 的坐标原点在左上角,y 轴向下。`anchor` 是 0-1 归一化坐标,`foot_y` 是从画布顶部开始计算的像素值。 + +## 为什么缺一帧就全部失败 + +`expectedFrameCount` 表示后端声明的完整帧数,不能用 `frames.length` 自己推算。两者不同、图片读取失败、PNG 无透明信息或尺寸不一致时,导出立即失败,不会用透明占位掩盖问题。`action-assets` 可保留 `pending` 质量状态供检查;`playtest` 只接受 `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 校验、连续命名、图集输出、缺帧失败、质量门禁、图片释放和空 target 扩展。 + +## 当前主线接线 + +- WorkflowRun 的完整动画结果可在发布前以 `pending` 动作资产导出;Character 资产树中的已发布动作标记为 `passed`。 +- 帧顺序使用后端显式 `Frame.index`,断号或重复序号会在读取图片前失败。 +- `durationMs` 为空时才按 Action FPS 计算,不覆盖后端逐帧时长。 +- 锚点和脚线沿用 `ai_engine.align_bottom_center` 的底部居中与 `0.92` 脚线约定。 +- 当前 Character 只表达单方向动作,因此统一导出为 `default`;四向和八向需等待资产契约扩展。 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 00000000..c190b7a7 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -0,0 +1,537 @@ +/** @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, validateExportPackageModel } from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel } from './model' + +function frame(index: number): ExportFrame { + return { + index, + imageUrl: `/frames/walk-${index}.png`, + durationMs: 100, + } +} + +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 = { + stage: 'action-assets', + characterId: 'character-1', + characterName: 'Aster', + characterImageUrl: '/master.png', + outfitId: 'outfit-1', + outfitName: 'Explorer', + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + firstFrames: [ + { actionId: 'walk-abcdef12', name: 'Walk', type: 'walk', fps: 10, imageUrl: '/walk-0.png' }, + ], + actions: [action()], + playtest: null, +} + +/** 构造足够让契约检查识别为 RGBA PNG 的文件头,解码由测试运行时接管。 */ +function rgbaPng(type = 'image/png'): 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 }) +} + +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('拒绝把不完整内容标记成更高导出阶段', () => { + const cases: Array<[ExportPackageModel, string]> = [ + [{ ...model, stage: 'unknown' as ExportPackageModel['stage'] }, 'stage: 不是支持的导出阶段'], + [ + { ...model, stage: 'first-frame', firstFrames: [], actions: [] }, + 'firstFrames: 首帧阶段至少需要一个已确认首帧', + ], + [{ ...model, stage: 'action-assets', actions: [] }, 'actions: 当前阶段至少需要一个完整动作'], + [{ ...model, stage: 'playtest', playtest: null }, 'playtest: Playtest 阶段必须包含运行配置'], + [ + { ...model, stage: 'character', actions: [], playtest: { initialActionId: null } }, + 'playtest: 只有 Playtest 阶段可以包含运行配置', + ], + ] + + for (const [candidate, message] of cases) { + expect(() => validateExportPackageModel(candidate)).toThrow(message) + } + }) + + it('角色阶段只打包母版,后续阶段在同一根目录增量追加首帧和 Playtest 清单', async () => { + const characterModel: ExportPackageModel = { + ...model, + stage: 'character', + characterImageUrl: '/master.png', + firstFrames: [], + actions: [], + playtest: null, + } + const firstFrameModel: ExportPackageModel = { + ...characterModel, + stage: 'first-frame', + firstFrames: [ + { + actionId: 'walk', + name: 'Walk', + type: 'walk', + fps: 10, + imageUrl: '/walk-first.png', + }, + ], + } + const playtestModel: ExportPackageModel = { + ...firstFrameModel, + stage: 'playtest', + actions: model.actions, + playtest: { initialActionId: 'walk-abcdef12' }, + } + + const characterEntries = await readStoredZip( + (await exportGameAssets(characterModel, { runtime: runtime() })).blob, + ) + const firstFrameEntries = await readStoredZip( + (await exportGameAssets(firstFrameModel, { runtime: runtime() })).blob, + ) + const playtestEntries = await readStoredZip( + (await exportGameAssets(playtestModel, { runtime: runtime() })).blob, + ) + const root = 'Aster-character-1-Explorer-outfit-1' + + expect([...characterEntries.keys()]).toContain(`${root}/character/master.png`) + expect([...characterEntries.keys()].every((name) => firstFrameEntries.has(name))).toBe(true) + expect(firstFrameEntries.has(`${root}/first-frames/Walk-walk.png`)).toBe(true) + expect([...firstFrameEntries.keys()].every((name) => playtestEntries.has(name))).toBe(true) + expect(playtestEntries.has(`${root}/playtest.json`)).toBe(true) + + const playtest = JSON.parse( + new TextDecoder().decode(playtestEntries.get(`${root}/playtest.json`)), + ) + expect(playtest).toEqual({ + schema_version: '1.1.0', + initial_action_id: 'walk-abcdef12', + action_ids: ['walk-abcdef12'], + }) + + const meta = JSON.parse(new TextDecoder().decode(playtestEntries.get(`${root}/meta.json`))) + expect(meta).toMatchObject({ + stage: 'playtest', + character: { image: 'character/master.png' }, + first_frames: [{ action_id: 'walk', file: 'first-frames/Walk-walk.png' }], + playtest: { initial_action_id: 'walk-abcdef12' }, + }) + }) + + 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', + 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、图集、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-Explorer-outfit-1' + const names = [...entries.keys()] + + expect(result.filename).toBe('windup-Aster-character-1-Explorer-outfit-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.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.1.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(names.some((name) => name.endsWith('.gif'))).toBe(false) + }) + + it('把 Outfit 标识写入包名,避免同一 Character 的不同造型互相覆盖', async () => { + const otherOutfit = { + ...model, + outfitId: 'outfit-2', + outfitName: 'Armored', + } + + const first = await exportGameAssets(model, { runtime: runtime() }) + const second = await exportGameAssets(otherOutfit, { runtime: runtime() }) + + expect(first.filename).toBe('windup-Aster-character-1-Explorer-outfit-1.zip') + expect(second.filename).toBe('windup-Aster-character-1-Armored-outfit-2.zip') + expect(first.filename).not.toBe(second.filename) + }) + + it('响应 MIME 缺失或过于通用时仍按 PNG 文件字节完成校验', async () => { + const baseRuntime = runtime() + const untypedRuntime: AssetExportRuntime = { + ...baseRuntime, + fetchFrame: vi.fn(async () => rgbaPng('application/octet-stream')), + } + + await expect(exportGameAssets(model, { runtime: untypedRuntime })).resolves.toMatchObject({ + filename: 'windup-Aster-character-1-Explorer-outfit-1.zip', + }) + }) + + 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('帧序号不是从 0 连续排列时,在读取图片前拒绝导出', async () => { + const frames = action().sequences[0]!.frames.map((item, index) => + index === 4 ? { ...item, index: 7 } : item, + ) + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, frames }], + }, + ], + } + const testRuntime = runtime() + + await expect(exportGameAssets(badModel, { runtime: testRuntime })).rejects.toThrow( + 'actions[0].sequences[0].frames[4].index: 必须连续且等于 4', + ) + 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, + stage: 'playtest', + playtest: { initialActionId: null }, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, qualityStatus: 'pending' }], + }, + ], + } + + await expect(exportGameAssets(badModel, { runtime: runtime() })).rejects.toThrow( + 'actions[0].sequences[0].qualityStatus: 质量检测未通过,禁止导出', + ) + }) + + it('脚底线超出画布时在读取图片前拒绝导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, footY: 41 }], + }, + ], + } + const testRuntime = runtime() + + await expect(exportGameAssets(badModel, { runtime: testRuntime })).rejects.toThrow( + 'actions[0].sequences[0].footY: 必须是 0 到 40 的整数像素值', + ) + expect(testRuntime.fetchFrame).not.toHaveBeenCalled() + }) + + it('拒绝没有透明通道的 PNG', async () => { + const data = new Uint8Array(await rgbaPng().arrayBuffer()) + data[25] = 2 + const testRuntime: AssetExportRuntime = { + ...runtime(), + fetchFrame: vi.fn(async () => new Blob([data], { type: 'image/png' })), + } + + await expect(exportGameAssets(model, { runtime: testRuntime })).rejects.toThrow( + 'PNG 必须包含 Alpha 透明通道', + ) + expect(testRuntime.decodeFrame).not.toHaveBeenCalled() + }) + + it('PNG 解码失败时带上具体帧路径', async () => { + const testRuntime: AssetExportRuntime = { + ...runtime(), + decodeFrame: vi.fn(async () => { + throw new Error('corrupt image') + }), + } + + await expect(exportGameAssets(model, { runtime: testRuntime })).rejects.toThrow( + 'character/master.png: PNG 解码失败(corrupt image)', + ) + }) + + it('任一帧尺寸与项目画布不一致时关闭图片并拒绝导出', async () => { + const closes: Array> = [] + const testRuntime: AssetExportRuntime = { + ...runtime(), + decodeFrame: vi.fn(async () => { + const close = vi.fn() + closes.push(close) + return { + source: {} as CanvasImageSource, + width: 31, + height: 40, + close, + } + }), + } + + await expect(exportGameAssets(model, { runtime: testRuntime })).rejects.toThrow( + '画布应为 32x40,实际为 31x40', + ) + expect(closes).toHaveLength(2) + expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true) + }) + + it('浏览器无法编码图集时释放图片并拒绝导出', async () => { + const testRuntime: AssetExportRuntime = { + ...runtime(), + 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(), + }), + }) + Object.defineProperty(canvas, 'toBlob', { + value: (callback: BlobCallback) => callback(null), + }) + return canvas + }), + } + + await expect(exportGameAssets(model, { runtime: testRuntime })).rejects.toThrow( + 'atlas: PNG 编码失败', + ) + }) + + it('target 只能写入安全且不重复的相对路径', async () => { + const unsafeTarget: AssetExportTarget = { + id: 'unsafe', + createFiles: vi.fn(async () => [{ path: '../escape.json', data: '{}' }]), + } + await expect( + exportGameAssets(model, { runtime: runtime(), targets: [unsafeTarget] }), + ).rejects.toThrow('targets.unsafe.files[0].path: 必须是安全的相对路径') + + const duplicateTarget: AssetExportTarget = { + id: 'duplicate', + createFiles: vi.fn(async () => [ + { path: 'same.json', data: '{}' }, + { path: 'same.json', data: new Uint8Array([1]) }, + ]), + } + await expect( + exportGameAssets(model, { + runtime: runtime(), + targets: [duplicateTarget], + }), + ).rejects.toThrow('package.files: 文件路径重复') + }) + + 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-Explorer-outfit-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(11) + 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 00000000..49ceadc0 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.ts @@ -0,0 +1,636 @@ +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 + 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。 */ +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 LoadedStaticAsset { + relativeFile: string + data: Uint8Array + decoded: DecodedFrame +} + +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 [model.characterName, model.characterId, model.outfitName, model.outfitId] + .map((value, index) => safeSegment(value, index % 2 === 0 ? 'asset' : 'id')) + .join('-') +} + +function firstFrameFile(actionId: string, name: string): string { + return `first-frames/${safeSegment(name, 'action')}-${idSuffix(actionId)}.png` +} + +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`, + columns, + rows: Math.ceil(sequence.frames.length / columns), + frames: sequence.frames.map((currentFrame) => { + const filename = `${exportName}_${String(currentFrame.index).padStart(3, '0')}.png` + return { + frame: currentFrame, + index: 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})`) + } + 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), + })) +} + +async function loadStaticAssets( + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const planned = [ + { relativeFile: 'character/master.png', imageUrl: model.characterImageUrl }, + ...model.firstFrames.map((frame) => ({ + relativeFile: firstFrameFile(frame.actionId, frame.name), + imageUrl: frame.imageUrl, + })), + ] + const cache = new Map>() + const settled = await Promise.allSettled( + planned.map(async (asset) => { + const loaded = await loadFrame( + { + frame: { index: 0, imageUrl: asset.imageUrl, durationMs: 1 }, + index: 0, + filename: asset.relativeFile.split('/').at(-1)!, + relativeFile: asset.relativeFile, + }, + runtime, + cache, + ) + if ( + loaded.decoded.width !== model.canvas.width || + loaded.decoded.height !== model.canvas.height + ) { + loaded.decoded.close() + throw new Error( + `${asset.relativeFile}: 画布应为 ${model.canvas.width}x${model.canvas.height},实际为 ${loaded.decoded.width}x${loaded.decoded.height}`, + ) + } + return { relativeFile: asset.relativeFile, data: loaded.data, decoded: loaded.decoded } + }), + ) + const fulfilled = settled.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ) + const failure = settled.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') { + fulfilled.forEach((asset) => asset.decoded.close()) + throw failure.reason + } + return fulfilled +} + +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 createMetadata( + model: ExportPackageModel, + plan: readonly PlannedSequence[], +): GenericExportMetadata { + return { + schema_version: EXPORT_PACKAGE_SCHEMA_VERSION, + stage: model.stage, + character: { + id: model.characterId, + name: model.characterName, + image: 'character/master.png', + }, + outfit: { id: model.outfitId, name: model.outfitName }, + canvas: { w: model.canvas.width, h: model.canvas.height }, + first_frames: model.firstFrames.map((frame) => ({ + action_id: frame.actionId, + name: frame.name, + type: frame.type, + fps: frame.fps, + file: firstFrameFile(frame.actionId, frame.name), + })), + actions: plan.map((item) => ({ + id: item.action.id, + name: item.exportName, + fps: item.action.fps, + loop: item.sequence.loop, + quality_status: item.sequence.qualityStatus, + 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 }, + }, + })), + source: + model.source === null + ? null + : { + workflow_run_id: model.source.workflowRunId, + generation_ids: [...model.source.generationIds], + }, + playtest: + model.playtest === null ? null : { initial_action_id: model.playtest.initialActionId }, + } +} + +function createReadme(model: ExportPackageModel): string { + return `# ${model.characterName} 导出包 + +这是 Windup 通用资产包,契约版本为 ${EXPORT_PACKAGE_SCHEMA_VERSION}。 + +## 内容 + +- \`meta.json\`: 动作、帧率、循环、画布、锚点、脚底线、图集与生成记录。 +- \`frames//\`: 连续编号的透明 PNG 原始帧。 +- \`atlas/.png\`: 按 \`meta.json\` 中 cols、rows 和 cell 切分的图集。 +- \`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 staticAssets = await loadStaticAssets(model, runtime) + let loaded: readonly LoadedSequence[] + try { + loaded = await loadAllFrames(plan, model, runtime) + } catch (error) { + staticAssets.forEach((asset) => asset.decoded.close()) + throw error + } + const root = packageRoot(model) + const entries: ZipEntry[] = [] + + try { + options.onPhase?.('rendering') + staticAssets.forEach((asset) => { + entries.push({ name: `${root}/${asset.relativeFile}`, data: asset.data }) + }) + 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}/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)) }, + ) + if (model.playtest !== null) { + entries.push({ + name: `${root}/playtest.json`, + data: await bytes( + JSON.stringify( + { + schema_version: EXPORT_PACKAGE_SCHEMA_VERSION, + initial_action_id: model.playtest.initialActionId, + action_ids: model.actions.map((action) => action.id), + }, + null, + 2, + ), + ), + }) + } + + 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 { + staticAssets.forEach((asset) => asset.decoded.close()) + 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/character-export.test.ts b/frontend/src/features/export-package/character-export.test.ts new file mode 100644 index 00000000..20ad244f --- /dev/null +++ b/frontend/src/features/export-package/character-export.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' + +import type { Character, Project } from '@/entities' + +import { createCharacterExportModel } from './character-export' + +const project: Project = { + id: '42', + workflowId: null, + name: '点灯人', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 64, height: 80 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-01T08:00:00Z', + updatedAt: '2026-08-01T08:00:00Z', +} + +const character: Character = { + id: '51', + projectId: '42', + workflowRunId: '501', + name: '轻装信使', + description: null, + referenceImageUrl: '/master.png', + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: null, + previewUrl: '/master.png', + actions: [ + { + id: 'walk', + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + loop: true, + fps: 10, + frameCount: 3, + frames: [ + { index: 2, imageUrl: '/walk-03.png', durationMs: 120 }, + { index: 0, imageUrl: '/walk-01.png', durationMs: null }, + { index: 1, imageUrl: '/walk-02.png', durationMs: 90 }, + ], + }, + ], + }, + ], +} + +describe('createCharacterExportModel', () => { + it('maps the current Project and Character contracts without losing frame indexes', () => { + const model = createCharacterExportModel({ + project, + character, + outfitId: 'outfit-default', + }) + + expect(model).toMatchObject({ + stage: 'action-assets', + characterId: '51', + characterName: '轻装信使', + characterImageUrl: '/master.png', + outfitId: 'outfit-default', + outfitName: '常态造型', + canvas: { width: 64, height: 80 }, + source: { workflowRunId: '501', generationIds: [] }, + }) + expect(model.actions[0]?.sequences[0]).toMatchObject({ + direction: 'default', + expectedFrameCount: 3, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + footY: 73, + qualityStatus: 'passed', + }) + expect(model.actions[0]?.sequences[0]?.frames).toEqual([ + { index: 0, imageUrl: '/walk-01.png', durationMs: 100 }, + { index: 1, imageUrl: '/walk-02.png', durationMs: 90 }, + { index: 2, imageUrl: '/walk-03.png', durationMs: 120 }, + ]) + }) + + it('rejects a frame sequence whose explicit backend indexes are not contiguous', () => { + const invalid: Character = { + ...character, + outfits: [ + { + ...character.outfits[0]!, + actions: [ + { + ...character.outfits[0]!.actions[0]!, + frames: [ + { index: 0, imageUrl: '/walk-01.png', durationMs: 100 }, + { index: 2, imageUrl: '/walk-03.png', durationMs: 100 }, + ], + }, + ], + }, + ], + } + + expect(() => + createCharacterExportModel({ + project, + character: invalid, + outfitId: 'outfit-default', + }), + ).toThrow('行走的帧序号必须从 0 连续排列') + }) + + it('rejects invalid project, character, outfit and timing relationships', () => { + expect(() => + createCharacterExportModel({ + project, + character: { ...character, projectId: 'other-project' }, + outfitId: 'outfit-default', + }), + ).toThrow('角色与项目不匹配') + + expect( + createCharacterExportModel({ + project, + character: { ...character, name: ' ' }, + outfitId: 'outfit-default', + }).characterName, + ).toBe('未命名角色') + + expect(() => + createCharacterExportModel({ + project, + character, + outfitId: 'missing-outfit', + }), + ).toThrow('导出造型不存在') + + const invalidTiming: Character = { + ...character, + outfits: [ + { + ...character.outfits[0]!, + actions: [ + { + ...character.outfits[0]!.actions[0]!, + fps: 0, + frames: [{ index: 0, imageUrl: '/walk-01.png', durationMs: null }], + }, + ], + }, + ], + } + expect(() => + createCharacterExportModel({ + project, + character: invalidTiming, + outfitId: 'outfit-default', + }), + ).toThrow('行走缺少有效的帧时长和 FPS') + }) +}) diff --git a/frontend/src/features/export-package/character-export.ts b/frontend/src/features/export-package/character-export.ts new file mode 100644 index 00000000..b9eb781e --- /dev/null +++ b/frontend/src/features/export-package/character-export.ts @@ -0,0 +1,18 @@ +import type { Character, Project } from '@/entities' + +import type { ExportPackageModel } from './model' +import { createProgressiveExportModel } from './progressive-export' + +export interface CreateCharacterExportModelInput { + project: Project + character: Character + outfitId: string +} + +export function createCharacterExportModel({ + project, + character, + outfitId, +}: CreateCharacterExportModelInput): ExportPackageModel { + return createProgressiveExportModel({ project, character, outfitId }) +} 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 00000000..5085f2a1 --- /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 00000000..afed8ce7 --- /dev/null +++ b/frontend/src/features/export-package/contract.ts @@ -0,0 +1,153 @@ +import exportSchemaText from './export-package.schema.json?raw' +import type { ExportPackageModel } from './model' + +export const EXPORT_PACKAGE_SCHEMA_VERSION = '1.1.0' +export const EXPORT_PACKAGE_JSON_SCHEMA_TEXT = exportSchemaText + +export interface GenericExportFrame { + index: number + file: string +} + +export interface GenericExportAction { + id: string + name: string + fps: number + loop: boolean + quality_status: ExportPackageModel['actions'][number]['sequences'][number]['qualityStatus'] + frames: readonly GenericExportFrame[] + anchor: { x: number; y: number } + foot_y: number + atlas: { + file: string + cols: number + rows: number + cell: { w: number; h: number } + } +} + +export interface GenericExportMetadata { + schema_version: typeof EXPORT_PACKAGE_SCHEMA_VERSION + stage: ExportPackageModel['stage'] + character: { id: string; name: string; image: string } + outfit: { id: string; name: string } + canvas: { w: number; h: number } + first_frames: readonly { + action_id: string + name: string + type: string + fps: number + file: string + }[] + actions: readonly GenericExportAction[] + playtest: { initial_action_id: string | null } | null + source: { + workflow_run_id: string + generation_ids: readonly string[] + } | null +} + +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 { + if (!['character', 'first-frame', 'action-assets', 'playtest'].includes(model.stage)) { + fail('stage', '不是支持的导出阶段') + } + requireText('character.id', model.characterId) + requireText('character.name', model.characterName) + requireText('character.imageUrl', model.characterImageUrl) + requireText('outfit.id', model.outfitId) + requireText('outfit.name', model.outfitName) + requirePositiveInteger('canvas.w', model.canvas.width) + requirePositiveInteger('canvas.h', model.canvas.height) + if (model.source !== null) { + requireText('source.workflow_run_id', model.source.workflowRunId) + 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) + }) + } + + model.firstFrames.forEach((frame, index) => { + const field = `firstFrames[${index}]` + requireText(`${field}.actionId`, frame.actionId) + requireText(`${field}.name`, frame.name) + requirePositiveInteger(`${field}.fps`, frame.fps) + requireText(`${field}.imageUrl`, frame.imageUrl) + }) + if (model.stage === 'first-frame' && model.firstFrames.length === 0) { + fail('firstFrames', '首帧阶段至少需要一个已确认首帧') + } + if ( + (model.stage === 'action-assets' || model.stage === 'playtest') && + model.actions.length === 0 + ) { + fail('actions', '当前阶段至少需要一个完整动作') + } + if (model.stage === 'playtest' && model.playtest === null) { + fail('playtest', 'Playtest 阶段必须包含运行配置') + } + if (model.stage !== 'playtest' && model.playtest !== null) { + fail('playtest', '只有 Playtest 阶段可以包含运行配置') + } + 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 (model.stage === 'playtest' && 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) => { + if (frame.index !== frameIndex) { + fail(`${sequenceField}.frames[${frameIndex}].index`, `必须连续且等于 ${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 00000000..bad57fbf --- /dev/null +++ b/frontend/src/features/export-package/export-package.schema.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://windup.local/schemas/export-package-1.1.0.json", + "title": "Windup Generic Export Package", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "stage", + "character", + "outfit", + "canvas", + "first_frames", + "actions", + "playtest", + "source" + ], + "properties": { + "schema_version": { "const": "1.1.0" }, + "stage": { "enum": ["character", "first-frame", "action-assets", "playtest"] }, + "character": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "image"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "image": { "const": "character/master.png" } + } + }, + "outfit": { + "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", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "fps", + "loop", + "quality_status", + "frames", + "anchor", + "foot_y", + "atlas" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "fps": { "type": "integer", "minimum": 1 }, + "loop": { "type": "boolean" }, + "quality_status": { "enum": ["passed", "pending", "failed"] }, + "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 } + } + } + } + } + } + } + }, + "first_frames": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["action_id", "name", "type", "fps", "file"], + "properties": { + "action_id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "type": { "type": "string", "minLength": 1 }, + "fps": { "type": "integer", "minimum": 1 }, + "file": { "type": "string", "pattern": "^first-frames/.+\\.png$" } + } + } + }, + "playtest": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["initial_action_id"], + "properties": { + "initial_action_id": { "type": ["string", "null"] } + } + } + ] + }, + "source": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["workflow_run_id", "generation_ids"], + "properties": { + "workflow_run_id": { "type": "string", "minLength": 1 }, + "generation_ids": { + "type": "array", + "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 00000000..93bb857a --- /dev/null +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -0,0 +1,170 @@ +/** @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 = { + stage: 'action-assets', + characterId: 'character-1', + characterName: 'Aster', + characterImageUrl: '/master.png', + outfitId: 'outfit-1', + outfitName: 'Explorer', + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + firstFrames: [ + { actionId: 'walk-abcdef12', name: 'Walk', type: 'walk', fps: 10, imageUrl: '/walk.png' }, + ], + 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: [ + { + index: 0, + imageUrl: '/walk.png', + durationMs: 100, + }, + ], + }, + ], + }, + ], + playtest: null, +} 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) + }) + + it('只有角色母版、还没有动作时也允许导出基础包', async () => { + const exporter = vi.fn().mockResolvedValue({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-character.zip', + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:character'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render( + , + ) + + expect(screen.getByText('当前包含角色母版')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '导出游戏资产包' })) + await waitFor(() => expect(exporter).toHaveBeenCalledTimes(1)) + }) + + it('导出器抛出非 Error 值时展示通用错误', async () => { + const exporter = vi.fn().mockRejectedValue('network unavailable') + + render() + fireEvent.click(screen.getByRole('button', { name: '导出游戏资产包' })) + + await waitFor(() => expect(screen.getByText('导出失败:未知错误')).toBeTruthy()) + }) +}) 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 00000000..864fdbc3 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.tsx @@ -0,0 +1,167 @@ +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 +} + +export interface ExportButtonProps { + model: ExportPackageModel + exporter?: AssetExporter + className?: string +} + +type ExportState = + | { status: 'idle' } + | { status: 'working'; phase: AssetExportPhase } + | { status: 'success' } + | { status: 'failure'; message: string } + +const PHASE_LABELS: Readonly> = { + validating: '正在检查导出条件', + collecting: '正在整理素材', + rendering: '正在生成图片', + packing: '正在打包', +} + +const STAGE_LABELS: Readonly> = { + character: '角色母版', + 'first-frame': '角色母版与动作首帧', + 'action-assets': '完整动作资产', + playtest: 'Playtest 运行包', +} + +const defaultExporter: AssetExporter = (model, onPhase) => exportGameAssets(model, { onPhase }) + +function useExportAction(model: ExportPackageModel, exporter: AssetExporter) { + const [state, setState] = useState({ status: 'idle' }) + 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 { state, working, startExport } +} + +export function ExportPanel({ + model, + qualityIssueCount = 0, + exporter = defaultExporter, +}: ExportPanelProps) { + const plan = createAssetExportPlan(model) + const { state, working, startExport } = useExportAction(model, exporter) + + return ( +
+
+

GAME ASSETS

+

资产导出

+

逐帧透明 PNG、Sprite Sheet 与动画 JSON

+
+ +
+
当前阶段
+
{STAGE_LABELS[model.stage]}
+
已确认首帧
+
{model.firstFrames.length} 张
+
动作方向
+
{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} +
+ ) +} + +export function ExportButton({ + model, + exporter = defaultExporter, + className = '', +}: ExportButtonProps) { + const { state, working, startExport } = useExportAction(model, exporter) + const label = + state.status === 'working' + ? PHASE_LABELS[state.phase] + : state.status === 'failure' + ? '重新导出' + : `导出${STAGE_LABELS[model.stage]}` + + return ( + + ) +} diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts new file mode 100644 index 00000000..29acc5cf --- /dev/null +++ b/frontend/src/features/export-package/index.ts @@ -0,0 +1,35 @@ +/** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ +export { ExportButton, ExportPanel } from './export-panel' +export type { ExportButtonProps, ExportPanelProps } from './export-panel' +export type { + ExportAction, + ExportAnchor, + ExportFrame, + ExportPackageModel, + ExportQualityStatus, + ExportSequence, + ExportSourceReference, + ExportStage, +} from './model' +export { createCharacterExportModel } from './character-export' +export type { CreateCharacterExportModelInput } from './character-export' +export { createProgressiveExportModel } from './progressive-export' +export type { CreateProgressiveExportModelInput } from './progressive-export' +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 00000000..859e675d --- /dev/null +++ b/frontend/src/features/export-package/model.ts @@ -0,0 +1,78 @@ +import type { ActionType } from '@/entities' + +/** + * 导出模块只读取这份模型,不直接读取 Playtest 页面状态。 + * 页面或后端适配器负责把当前角色、动作和生成记录整理成该模型。 + */ +export interface ExportFrame { + index: number + imageUrl: string + durationMs: number +} + +/** 通用契约使用左上角为原点、y 轴向下的 0-1 归一化坐标。 */ +export interface ExportAnchor { + x: number + y: number +} + +export type ExportQualityStatus = 'passed' | 'pending' | 'failed' +export type ExportStage = 'character' | 'first-frame' | 'action-assets' | 'playtest' + +export interface ExportFirstFrame { + actionId: string + name: string + type: ActionType | 'crouch' + fps: number + imageUrl: string +} + +export interface ExportPlaytest { + initialActionId: string | null +} + +export interface ExportSequence { + direction: string + /** 后端声明的完整帧数;不能用 frames.length 代替,否则无法发现缺帧。 */ + expectedFrameCount: number + loop: boolean + anchor: ExportAnchor + /** 脚底线距离画布顶部的像素值。 */ + footY: number + /** 生成完成可为 pending;进入 Playtest 运行包前必须是 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 { + stage: ExportStage + characterId: string + characterName: string + characterImageUrl: string + outfitId: string + outfitName: string + /** 同一导出包内所有帧必须使用相同画布尺寸。 */ + canvas: { + width: number + height: number + } + /** 生成链路引用,用于从导出物追溯到 WorkflowRun 与生成任务。 */ + /** 独立 Playtest 入口可能没有 WorkflowRun/Generation 追溯信息。 */ + source: ExportSourceReference | null + firstFrames: readonly ExportFirstFrame[] + actions: readonly ExportAction[] + playtest: ExportPlaytest | null +} diff --git a/frontend/src/features/export-package/progressive-export.test.ts b/frontend/src/features/export-package/progressive-export.test.ts new file mode 100644 index 00000000..f9a2050c --- /dev/null +++ b/frontend/src/features/export-package/progressive-export.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' + +import type { Character, Generation, Project, WorkflowRun } from '@/entities' + +import { createProgressiveExportModel } from './progressive-export' + +const project: Project = { + id: 'project-1', + workflowId: null, + name: '像素项目', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 32, height: 40 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-01T08:00:00Z', + updatedAt: '2026-08-01T08:00:00Z', +} + +const character: Character = { + id: 'character-1', + projectId: project.id, + workflowRunId: 'run-1', + name: null, + description: null, + referenceImageUrl: '/master.png', + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: '默认造型', + description: null, + previewUrl: '/master.png', + actions: [], + }, + ], +} + +const run: WorkflowRun = { + id: 'run-1', + projectId: project.id, + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { name: '无名旅人', characterId: 'character-1', prompt: '', referenceMedia: [] }, + }, + { + id: 'template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['setup'], + generations: [{ taskId: 'generation-template', role: 'character_template' }], + error: null, + selectedImageUrl: '/master.png', + }, + { + id: 'walk-first', + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['template'], + generations: [{ taskId: 'generation-first', role: 'first_frame' }], + error: null, + input: { + outfitId: 'outfit-1', + name: '行走', + type: 'walk', + prompt: null, + fps: 10, + }, + selectedFirstFrameUrl: '/walk-first.png', + }, + ], +} + +describe('createProgressiveExportModel', () => { + it('拒绝把其它 WorkflowRun 的完成度拼到当前角色', () => { + expect(() => + createProgressiveExportModel({ + project, + character, + outfitId: 'outfit-1', + run: { ...run, id: 'other-run' }, + }), + ).toThrow('WorkflowRun 与角色或项目不匹配') + }) + + it('角色母版完成后即可构造不含动作的基础导出包', () => { + const model = createProgressiveExportModel({ + project, + character, + outfitId: 'outfit-1', + }) + + expect(model).toMatchObject({ + stage: 'character', + characterName: '未命名角色', + characterImageUrl: '/master.png', + firstFrames: [], + actions: [], + playtest: null, + }) + }) + + it('WorkflowRun 中已确认的首帧会在基础包上增量出现', () => { + const model = createProgressiveExportModel({ project, character, outfitId: 'outfit-1', run }) + + expect(model.stage).toBe('first-frame') + expect(model.characterName).toBe('无名旅人') + expect(model.firstFrames).toEqual([ + { + actionId: 'walk-first', + name: '行走', + type: 'walk', + fps: 10, + imageUrl: '/walk-first.png', + }, + ]) + expect(model.source).toEqual({ + workflowRunId: 'run-1', + generationIds: ['generation-template', 'generation-first'], + }) + }) + + it('完整动作与 Playtest 只提升阶段,不丢失角色母版和首帧', () => { + const withAction: Character = { + ...character, + outfits: [ + { + ...character.outfits[0]!, + actions: [ + { + id: 'walk', + outfitId: 'outfit-1', + name: '行走', + type: 'walk', + loop: true, + fps: 10, + frameCount: 1, + frames: [{ index: 0, imageUrl: '/walk-0.png', durationMs: 100 }], + }, + ], + }, + ], + } + + const actionModel = createProgressiveExportModel({ + project, + character: withAction, + outfitId: 'outfit-1', + run, + }) + const playtestModel = createProgressiveExportModel({ + project, + character: withAction, + outfitId: 'outfit-1', + run, + playtest: { initialActionId: 'walk' }, + }) + + expect(actionModel.stage).toBe('action-assets') + expect(playtestModel.stage).toBe('playtest') + expect(playtestModel.characterImageUrl).toBe(actionModel.characterImageUrl) + expect(playtestModel.firstFrames).toEqual(actionModel.firstFrames) + expect(playtestModel.actions).toEqual(actionModel.actions) + expect(playtestModel.playtest).toEqual({ initialActionId: 'walk' }) + }) + + it('完整动画 Generation 完成后、发布到 Character 前即可导出动作资产', () => { + const actionRun: WorkflowRun = { + ...run, + nodes: [ + ...run.nodes, + { + id: 'walk-method', + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['walk-first'], + generations: [], + error: null, + method: 'video-cropping', + }, + { + id: 'walk-full', + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['walk-method'], + generations: [{ taskId: 'generation-full', role: 'complete_animation' }], + error: null, + }, + { + id: 'walk-review', + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: ['walk-full'], + generations: [], + error: null, + }, + ], + } + const generation = { + id: 'generation-full', + projectId: project.id, + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + frames: [{ index: 0, url: '/walk-0.png', durationMs: 100 }], + }, + } satisfies Generation<'complete_animation'> + + const model = createProgressiveExportModel({ + project, + character, + outfitId: 'outfit-1', + run: actionRun, + generations: [generation], + }) + + expect(model.stage).toBe('action-assets') + expect(model.actions[0]).toMatchObject({ id: 'walk-full', name: '行走' }) + expect(model.actions[0]?.sequences[0]).toMatchObject({ + qualityStatus: 'pending', + frames: [{ index: 0, imageUrl: '/walk-0.png', durationMs: 100 }], + }) + }) +}) diff --git a/frontend/src/features/export-package/progressive-export.ts b/frontend/src/features/export-package/progressive-export.ts new file mode 100644 index 00000000..40dfd33d --- /dev/null +++ b/frontend/src/features/export-package/progressive-export.ts @@ -0,0 +1,278 @@ +import type { + Action, + Character, + Frame, + Generation, + Project, + WorkflowActionInput, + WorkflowRun, +} from '@/entities' + +import type { + ExportAction, + ExportFirstFrame, + ExportFrame, + ExportPackageModel, + ExportPlaytest, +} from './model' + +const FOOT_LINE_RATIO = 0.92 + +export interface CreateProgressiveExportModelInput { + project: Project + character: Character + outfitId: string + run?: WorkflowRun | null + playtest?: ExportPlaytest | null + generations?: readonly Generation[] +} + +function orderedFrames(action: Action): readonly Frame[] { + const frames = [...action.frames].sort((left, right) => left.index - right.index) + const invalid = frames.find((frame, index) => frame.index !== index) + if (invalid !== undefined) throw new Error(`${action.name}的帧序号必须从 0 连续排列`) + return frames +} + +function durationMs(frame: Frame, action: Action): number { + if (frame.durationMs !== null && Number.isFinite(frame.durationMs) && frame.durationMs > 0) { + return Math.round(frame.durationMs) + } + if (!Number.isFinite(action.fps) || action.fps <= 0) { + throw new Error(`${action.name}缺少有效的帧时长和 FPS`) + } + return Math.max(1, Math.round(1000 / action.fps)) +} + +function exportFrames(action: Action): readonly ExportFrame[] { + return orderedFrames(action).map((frame) => ({ + index: frame.index, + imageUrl: frame.imageUrl, + durationMs: durationMs(frame, action), + })) +} + +function exportAction(action: Action, project: Project): ExportAction { + return { + id: action.id, + name: action.name, + type: action.type, + fps: action.fps, + sequences: [ + { + direction: 'default', + expectedFrameCount: action.frameCount, + loop: action.loop, + anchor: { x: 0.5, y: FOOT_LINE_RATIO }, + footY: Math.trunc(project.spriteSize.height * FOOT_LINE_RATIO), + qualityStatus: 'passed', + frames: exportFrames(action), + }, + ], + } +} + +function workflowFirstFrames(run: WorkflowRun | null | undefined, outfitId: string) { + if (!run) return [] + return run.nodes.flatMap((node): ExportFirstFrame[] => { + if ( + node.type !== 'action-first-frame' || + node.status !== 'passed' || + node.phase !== 'completed' || + node.input.outfitId !== outfitId || + !node.selectedFirstFrameUrl || + node.deletedAt + ) { + return [] + } + return [firstFrame(node.id, node.input, node.selectedFirstFrameUrl)] + }) +} + +function firstFrame( + actionId: string, + input: WorkflowActionInput, + imageUrl: string, +): ExportFirstFrame { + return { + actionId, + name: input.name, + type: input.type, + fps: input.fps, + imageUrl, + } +} + +function publishedFirstFrames(actions: readonly Action[]): readonly ExportFirstFrame[] { + return actions.flatMap((action) => { + const frame = orderedFrames(action)[0] + return frame + ? [ + { + actionId: action.id, + name: action.name, + type: action.type, + fps: action.fps, + imageUrl: frame.imageUrl, + }, + ] + : [] + }) +} + +function generatedActions( + project: Project, + run: WorkflowRun | null | undefined, + generations: readonly Generation[], + outfitId: string, +): readonly ExportAction[] { + if (!run) return [] + return run.nodes.flatMap((fullFrame): ExportAction[] => { + if ( + fullFrame.type !== 'action-full-frame' || + fullFrame.status !== 'passed' || + fullFrame.phase !== 'completed' || + fullFrame.deletedAt + ) { + return [] + } + const reference = fullFrame.generations.find((item) => item.role === 'complete_animation') + const generation = reference + ? generations.find((item) => item.id === reference.taskId) + : undefined + if ( + !generation || + generation.status !== 'completed' || + generation.result?.type !== 'complete_animation' + ) { + return [] + } + const method = run.nodes.find( + (node) => + node.type === 'action-generation-method' && fullFrame.dependsOnNodeIds.includes(node.id), + ) + const first = method + ? run.nodes.find( + (node) => node.type === 'action-first-frame' && method.dependsOnNodeIds.includes(node.id), + ) + : undefined + if (!first || first.type !== 'action-first-frame' || first.input.outfitId !== outfitId) + return [] + const frames = [...generation.result.frames] + .sort((left, right) => left.index - right.index) + .map((frame, index) => { + if (frame.index !== index) throw new Error(`${first.input.name}的帧序号必须从 0 连续排列`) + const durationMs = + frame.durationMs !== null && frame.durationMs > 0 + ? Math.round(frame.durationMs) + : Math.max(1, Math.round(1000 / first.input.fps)) + return { index: frame.index, imageUrl: frame.url, durationMs } + }) + const review = run.nodes.find( + (node) => node.type === 'review' && node.dependsOnNodeIds.includes(fullFrame.id), + ) + return [ + { + id: fullFrame.id, + name: first.input.name, + type: first.input.type, + fps: first.input.fps, + sequences: [ + { + direction: 'default', + expectedFrameCount: frames.length, + loop: true, + anchor: { x: 0.5, y: FOOT_LINE_RATIO }, + footY: Math.trunc(project.spriteSize.height * FOOT_LINE_RATIO), + qualityStatus: review?.status === 'passed' ? 'passed' : 'pending', + frames, + }, + ], + }, + ] + }) +} + +function mergeActions(published: readonly ExportAction[], generated: readonly ExportAction[]) { + const result = [...published] + for (const action of generated) { + if (!result.some((candidate) => candidate.id === action.id || candidate.name === action.name)) { + result.push(action) + } + } + return result +} + +function mergeFirstFrames( + workflowFrames: readonly ExportFirstFrame[], + actionFrames: readonly ExportFirstFrame[], +) { + const result = [...workflowFrames] + for (const frame of actionFrames) { + const existing = result.findIndex( + (candidate) => candidate.actionId === frame.actionId || candidate.name === frame.name, + ) + if (existing === -1) result.push(frame) + } + return result +} + +function characterName(character: Character, run: WorkflowRun | null | undefined) { + const setup = run?.nodes.find((node) => node.type === 'character-setup') + return character.name?.trim() || setup?.input.name?.trim() || '未命名角色' +} + +export function createProgressiveExportModel({ + project, + character, + outfitId, + run = null, + playtest = null, + generations = [], +}: CreateProgressiveExportModelInput): ExportPackageModel { + if (character.projectId !== project.id) throw new Error('角色与项目不匹配') + if (run && (run.id !== character.workflowRunId || run.projectId !== project.id)) { + throw new Error('WorkflowRun 与角色或项目不匹配') + } + const outfit = character.outfits.find((candidate) => candidate.id === outfitId) + if (!outfit) throw new Error('导出造型不存在') + const characterImageUrl = outfit.previewUrl || character.referenceImageUrl + if (!characterImageUrl) throw new Error('当前造型没有已确认的角色母版') + + const publishedActions = outfit.actions.map((action) => exportAction(action, project)) + const actions = mergeActions( + publishedActions, + playtest ? [] : generatedActions(project, run, generations, outfitId), + ) + const firstFrames = mergeFirstFrames( + workflowFirstFrames(run, outfitId), + publishedFirstFrames(outfit.actions), + ) + const generationIds = run + ? [...new Set(run.nodes.flatMap((node) => node.generations.map((item) => item.taskId)))] + : [] + + return { + stage: playtest + ? 'playtest' + : actions.length + ? 'action-assets' + : firstFrames.length + ? 'first-frame' + : 'character', + characterId: character.id, + characterName: characterName(character, run), + characterImageUrl, + outfitId: outfit.id, + outfitName: outfit.name, + canvas: { ...project.spriteSize }, + source: run + ? { workflowRunId: run.id, generationIds } + : character.workflowRunId + ? { workflowRunId: character.workflowRunId, generationIds: [] } + : null, + firstFrames, + actions, + playtest, + } +} diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 0d6f1f80..40cf578b 100644 --- a/frontend/src/pages/character-detail/index.test.tsx +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -41,7 +41,10 @@ describe('CharacterDetailPage', () => { ) expect(screen.queryByText('GIF')).toBeNull() expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(true) - expect(screen.getByRole('button', { name: '导出资产包' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: '导出游戏资产包' }).hasAttribute('disabled')).toBe( + false, + ) + expect(screen.queryByText('导出能力待 PR #97 合并并完成资产字段接线')).toBeNull() expect(screen.getByRole('link', { name: '试玩当前造型' }).getAttribute('href')).toBe( '/playtest/51/outfit-default', ) diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx index 6d05d1ed..ee70066a 100644 --- a/frontend/src/pages/character-detail/index.tsx +++ b/frontend/src/pages/character-detail/index.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { useEffect, useMemo, useState } from 'react' +import { Link, useOutletContext, useParams } from 'react-router' -import { characterApis, type Action, type Character, type Outfit } from '@/entities' +import { characterApis, type Action, type Character, type Outfit, type Project } from '@/entities' +import { createCharacterExportModel, ExportPanel } from '@/features/export-package' const ACTION_TYPE_LABELS: Record = { walk: '行走', @@ -24,6 +25,7 @@ function characterName(character: Character) { export function CharacterDetailPage() { const { projectId, characterId } = useParams() + const project = useOutletContext() const [character, setCharacter] = useState(null) const [selectedOutfitId, setSelectedOutfitId] = useState(null) const [error, setError] = useState(null) @@ -125,18 +127,7 @@ export function CharacterDetailPage() { 试玩当前造型 ) : null} - -

- 导出能力待 PR #97 合并并完成资产字段接线 -

@@ -149,6 +140,7 @@ export function CharacterDetailPage() {
+ )} @@ -156,6 +148,44 @@ export function CharacterDetailPage() { ) } +function CharacterExport({ + project, + character, + outfit, +}: { + project: Project + character: Character + outfit: Outfit +}) { + const result = useMemo(() => { + try { + return { + model: createCharacterExportModel({ project, character, outfitId: outfit.id }), + error: null, + } + } catch (error) { + return { + model: null, + error: error instanceof Error ? error.message : '资产数据无效', + } + } + }, [character, outfit.id, project]) + + if (result.error !== null) { + return ( +

+ 导出不可用:{result.error} +

+ ) + } + if (result.model === null || result.model.actions.length === 0) return null + return ( +
+ +
+ ) +} + function OutfitMaster({ character, outfit }: { character: Character; outfit: Outfit }) { const name = characterName(character) return ( diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx index d10daae6..e3a60a83 100644 --- a/frontend/src/pages/playtest/index.test.tsx +++ b/frontend/src/pages/playtest/index.test.tsx @@ -46,6 +46,7 @@ describe('PlaytestPage', () => { expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy() expect(screen.getByRole('button', { name: '绑定动作:呼吸待机' })).toBeTruthy() expect(screen.getByRole('button', { name: '绑定动作:行走' })).toBeTruthy() + expect(screen.getByRole('button', { name: '导出Playtest 运行包' })).toBeTruthy() }) it('plays frames in backend index order, not array order', async () => { @@ -98,4 +99,26 @@ describe('PlaytestPage', () => { expect(await screen.findByRole('heading', { name: '52 · 未命名造型' })).toBeTruthy() expect(screen.getByText('暂无可播放帧')).toBeTruthy() }) + + it('旧资产缺少角色母版时仍可试玩,但不显示无效导出入口', async () => { + const backend = createProjectAssetsBackend() + const fetchWithoutMaster: typeof globalThis.fetch = async (input, init) => { + const response = await backend.fetch(input, init) + if (new URL(new Request(input, init).url).pathname !== '/characters/51') return response + const body = (await response.json()) as { + data: { + reference_image_url: string | null + character_data: { outfits: Array<{ preview_url: string | null }> } + } + } + body.data.reference_image_url = null + body.data.character_data.outfits[0]!.preview_url = null + return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }) + } + + renderPlaytest('/playtest/51/outfit-default', fetchWithoutMaster) + + expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '导出Playtest 运行包' })).toBeNull() + }) }) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 5e038fac..11024969 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,20 +1,30 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { useParams, useSearchParams } from 'react-router' -import { characterApis, type Character } from '@/entities' +import { characterApis, projectApis, type Character, type Project } from '@/entities' import { ApiError } from '@/shared/api' import { PlaytestWorkbench } from './workbench' export { PlaytestEntryPage } from './entry' +export interface PlaytestPageProps { + renderToolbar?(context: { + project: Project + character: Character + outfitId: string + initialActionId: string | null + }): ReactNode +} + interface PageData { character: Character | null + project: Project | null error: string | null loading: boolean } -const initialPageData: PageData = { character: null, error: null, loading: false } +const initialPageData: PageData = { character: null, project: null, error: null, loading: false } /** * 后端把「角色不存在」表达成 HTTP 200 里的业务码 404,真正的传输失败才落在 status 上。 @@ -29,7 +39,7 @@ function isNotFoundError(error: unknown): boolean { * 页面只读 Character,不写回资产树,也不参与生成与审核。 * 读不到角色时直接报错,不退回任何内置数据。 */ -export function PlaytestPage() { +export function PlaytestPage({ renderToolbar }: PlaytestPageProps = {}) { const { characterId, outfitId } = useParams() const [searchParams] = useSearchParams() const initialActionId = searchParams.get('actionId') @@ -40,19 +50,25 @@ export function PlaytestPage() { let cancelled = false setData({ ...initialPageData, loading: true }) - void characterApis.get(characterId).then( - (character) => { - if (!cancelled) setData({ character, error: null, loading: false }) - }, - (error: unknown) => { - if (!cancelled) { - setData({ - ...initialPageData, - error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', - }) - } - }, - ) + void characterApis + .get(characterId) + .then(async (character) => ({ + character, + project: await projectApis.get(character.projectId), + })) + .then( + ({ character, project }) => { + if (!cancelled) setData({ character, project, error: null, loading: false }) + }, + (error: unknown) => { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', + }) + } + }, + ) return () => { cancelled = true @@ -62,14 +78,22 @@ export function PlaytestPage() { if (characterId === undefined || outfitId === undefined) return Playtest 路由参数不完整 if (data.error !== null) return {data.error} - if (data.loading || data.character === null) + if (data.loading || data.character === null || data.project === null) return 加载 Playtest 数据中 + const toolbar = renderToolbar?.({ + project: data.project, + character: data.character, + outfitId, + initialActionId, + }) + return ( ) } diff --git a/frontend/src/pages/playtest/workbench/index.tsx b/frontend/src/pages/playtest/workbench/index.tsx index 24f6498f..a4c2b1cf 100644 --- a/frontend/src/pages/playtest/workbench/index.tsx +++ b/frontend/src/pages/playtest/workbench/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, type PointerEvent } from 'react' +import { useMemo, type PointerEvent, type ReactNode } from 'react' import type { Character } from '@/entities' @@ -11,6 +11,7 @@ export interface PlaytestWorkbenchProps { readonly character: Character readonly outfitId: string readonly initialActionId?: string | null + readonly toolbar?: ReactNode } const directionLabels: Readonly> = { @@ -22,6 +23,7 @@ export function PlaytestWorkbench({ character, outfitId, initialActionId = null, + toolbar = null, }: PlaytestWorkbenchProps) { const result = useMemo(() => createPlaytestModel(character, outfitId), [character, outfitId]) @@ -35,14 +37,18 @@ export function PlaytestWorkbench({ ) } - return + return ( + + ) } function PlaytestExperience({ model, + toolbar, initialActionId, }: { readonly model: PlaytestModel + readonly toolbar: ReactNode readonly initialActionId: string | null }) { const runtime = usePlaytestRuntime(model.actions, initialActionId) @@ -76,7 +82,10 @@ function PlaytestExperience({ {model.outfitName} -

A / D 或方向键操控角色

+
+

A / D 或方向键操控角色

+ {toolbar} +
{/* diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx index 0d0806eb..ba622fad 100644 --- a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx +++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx @@ -28,7 +28,7 @@ const character: Character = { characterId: '51', name: '常态造型', description: null, - previewUrl: null, + previewUrl: '/master.png', actions: [ { id: IDLE_ACTION_ID, diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index b1944dff..ee035143 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -158,7 +158,7 @@ export function ProjectDetailPage() { data-route-transition={location.pathname} className="route-transition min-h-full" > - + diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index e7dd9f1a..d449529e 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { QuickStartEntryService, QuickStartSession } from './service' import type { WorkflowRun } from '@/entities' +import type { ExportPackageModel } from '@/features/export-package' import { QuickStartPage } from './index' afterEach(cleanup) @@ -126,6 +127,7 @@ function serviceFor(run: WorkflowRun | null, overrides: Partial resolveCharacterInfo: vi.fn(async () => ({ characterId: 'character-1', outfitId: 'outfit-1' })), getTemplateCandidates: vi.fn(async () => []), getActionFrames: vi.fn(async () => []), + getExportModel: vi.fn(async () => null), ...overrides, } Object.assign(service, overrides) @@ -149,6 +151,26 @@ function renderAt(path: string, service: QuickStartEntryService) { } describe('QuickStartPage', () => { + it('按当前 Run 完成度显示统一导出入口', async () => { + const run = workflow(setupAndTemplate({ selectedImageUrl: '/master.png' })) + const model: ExportPackageModel = { + stage: 'character', + characterId: 'character-1', + characterName: '像素骑士', + characterImageUrl: '/master.png', + outfitId: 'outfit-1', + outfitName: '默认造型', + canvas: { width: 32, height: 40 }, + source: { workflowRunId: run.id, generationIds: [] }, + firstFrames: [], + actions: [], + playtest: null, + } + renderAt('/quick-start/run-1', serviceFor(run, { getExportModel: vi.fn(async () => model) })) + + expect(await screen.findByRole('button', { name: '导出角色母版' })).toBeTruthy() + }) + it('keeps the entry and run canvases at least viewport height', async () => { const entry = renderAt('/quick-start', serviceFor(null)) expect( diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index fdd55f34..28d62312 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -16,6 +16,7 @@ import { type WorkflowNode, type WorkflowNodeType, } from '@/entities' +import { ExportButton, type ExportPackageModel } from '@/features/export-package' import { quickStartService, type QuickStartEntryService, @@ -408,6 +409,7 @@ function QuickStartRun({ const [candidates, setCandidates] = useState([]) const [firstFrameCandidates, setFirstFrameCandidates] = useState([]) const [actionFrames, setActionFrames] = useState([]) + const [exportModel, setExportModel] = useState(null) const [publishing, setPublishing] = useState(false) const [confirmingCandidate, setConfirmingCandidate] = useState(false) const [confirmingFirstFrame, setConfirmingFirstFrame] = useState(false) @@ -460,6 +462,7 @@ function QuickStartRun({ setCandidates([]) setFirstFrameCandidates([]) setActionFrames([]) + setExportModel(null) return } let active = true @@ -467,12 +470,14 @@ function QuickStartRun({ session.getTemplateCandidates(), session.getFirstFrameCandidates(), session.getActionFrames(), + session.getExportModel(), ]) - .then(([nextCandidates, nextFirstFrameCandidates, nextFrames]) => { + .then(([nextCandidates, nextFirstFrameCandidates, nextFrames, nextExportModel]) => { if (!active) return setCandidates(nextCandidates) setFirstFrameCandidates(nextFirstFrameCandidates) setActionFrames(nextFrames) + setExportModel(nextExportModel) }) .catch((cause) => { if (active) setError(errorMessage(cause, '读取生成结果失败')) @@ -629,22 +634,30 @@ function QuickStartRun({ {workflowPrompt(run) || '未命名角色创作'} -
-