diff --git a/docs/screenshots/workflow-editor-real-runtime-desktop.jpg b/docs/screenshots/workflow-editor-real-runtime-desktop.jpg new file mode 100644 index 00000000..5eb99096 Binary files /dev/null and b/docs/screenshots/workflow-editor-real-runtime-desktop.jpg differ diff --git a/docs/screenshots/workflow-editor-real-runtime-mobile.jpg b/docs/screenshots/workflow-editor-real-runtime-mobile.jpg new file mode 100644 index 00000000..f505d5fd Binary files /dev/null and b/docs/screenshots/workflow-editor-real-runtime-mobile.jpg differ diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 9aa32e8a..65774e82 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -47,12 +47,13 @@ Character 前端只映射后端真实字段: -- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` +- Character:`id`、`project_id`、`workflow_run_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` - Outfit:`id`、`name`、`description`、`preview_url`、`actions` - Action:`id`、`type`、`name`、`loop`、`fps`、`frame_count`、`frames` - Frame:`index`、`image_url`、`duration_ms` Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.outfitId` 仅由嵌套关系推导;修改任一子项时通过 `PATCH Character` 提交完整 `character_data`。 +创建 Character 时必须传入 `workflow_run_id`;编辑器也只读取与当前 WorkflowRun 绑定的角色,不能从同项目角色中按顺序猜测。 ## 二、本轮明确不实现 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d513d30c..7be3279f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "windup-frontend", "version": "0.0.0", "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" @@ -2019,6 +2020,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2047,7 +2097,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2057,7 +2107,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2202,6 +2252,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2268,6 +2360,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2299,9 +2397,114 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3376,6 +3579,15 @@ "dev": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -3625,6 +3837,34 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 0a845f78..330d0fbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc -b" }, "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index c8acde9b..40298bf5 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { AssetLibraryPage } from '@/pages/asset-library' @@ -9,10 +10,13 @@ import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' -import { WorkflowEditorPage } from '@/pages/workflow-editor' import { ProtectedRoute } from '@/features/auth-guard' import { AppShellRoute } from './layout' +const WorkflowEditorPage = lazy(() => + import('@/pages/workflow-editor').then(({ WorkflowEditorPage: Page }) => ({ default: Page })), +) + /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 @@ -27,6 +31,14 @@ export function App() { ) } +function LazyWorkflowEditorPage() { + return ( + }> + + + ) +} + /** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */ export function AppRoutes() { return ( @@ -38,8 +50,8 @@ export function AppRoutes() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/app/workflow-editor-route.test.tsx b/frontend/src/app/workflow-editor-route.test.tsx new file mode 100644 index 00000000..11e799c4 --- /dev/null +++ b/frontend/src/app/workflow-editor-route.test.tsx @@ -0,0 +1,35 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { MemoryRouter, Outlet } from 'react-router' + +import { AuthenticatedAuthSession } from '@/test/auth-session' + +afterEach(() => { + cleanup() + vi.doUnmock('@/pages/workflow-editor') + vi.doUnmock('./layout') + vi.resetModules() +}) + +it('只有进入 Workflow Editor 路由时才加载 React Flow 页面', async () => { + const pageModuleFactory = vi.fn(() => ({ + WorkflowEditorPage: () =>
懒加载 Workflow Editor
, + })) + vi.doMock('@/pages/workflow-editor', pageModuleFactory) + vi.doMock('./layout', () => ({ AppShellRoute: () => })) + + const { AppRoutes } = await import('./app') + expect(pageModuleFactory).not.toHaveBeenCalled() + + render( + + + + + , + ) + + expect(await screen.findByText('懒加载 Workflow Editor')).toBeTruthy() + expect(pageModuleFactory).toHaveBeenCalledTimes(1) +}) diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index af0a21de..1b731b11 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const characterDto = { id: 51, project_id: 42, + workflow_run_id: 77, name: '轻装信使', description: null, reference_image_url: 'https://cdn.windup.test/reference.png', @@ -86,6 +87,7 @@ describe('characterApis', () => { { id: '51', projectId: '42', + workflowRunId: '77', name: '轻装信使', description: null, referenceImageUrl: 'https://cdn.windup.test/reference.png', @@ -140,6 +142,7 @@ describe('characterApis', () => { await characterApis.create({ projectId: '42', + workflowRunId: '77', name: '轻装信使', description: '项目角色', referenceImageUrl: null, @@ -148,6 +151,7 @@ describe('characterApis', () => { expect(request?.method).toBe('POST') await expect(request?.json()).resolves.toEqual({ project_id: 42, + workflow_run_id: 77, name: '轻装信使', description: '项目角色', reference_image_url: null, diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 43c2cf6c..6d101e71 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -38,6 +38,8 @@ export interface Outfit { export interface Character { id: string projectId: string + /** 角色由哪一条制作流程产出;Workflow Editor 以它落实单画布单角色。 */ + workflowRunId: string name: string | null description: string | null referenceImageUrl: string | null @@ -50,6 +52,7 @@ export interface Character { /** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */ export interface CreateCharacterInput { projectId: string + workflowRunId: string name?: string | null description?: string | null referenceImageUrl?: string | null @@ -99,6 +102,7 @@ interface CharacterDataDto { interface CharacterDto { id: number project_id: number + workflow_run_id: number name: string | null description: string | null reference_image_url: string | null @@ -149,6 +153,7 @@ function mapCharacter(dto: CharacterDto): Character { return { id: characterId, projectId: String(dto.project_id), + workflowRunId: String(dto.workflow_run_id), name: dto.name, description: dto.description, referenceImageUrl: dto.reference_image_url, @@ -215,6 +220,7 @@ export const characterApis: CharacterApis = { method: 'POST', json: { project_id: toBackendId(input.projectId, 'projectId'), + workflow_run_id: toBackendId(input.workflowRunId, 'workflowRunId'), name: input.name, description: input.description, reference_image_url: input.referenceImageUrl, diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts new file mode 100644 index 00000000..c07157e1 --- /dev/null +++ b/frontend/src/features/export/index.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest' + +import type { Character, CharacterApis, Generation, WorkflowRun } from '@/entities' + +import * as exportFeature from './index' + +interface ExportFeatureModule { + createCharacterAssetPublisher(apis: Pick): { + publishReviewedAction(input: { + character: Character + workflow: WorkflowRun + reviewNodeId: string + generation: Generation + }): Promise + } +} + +describe('Character asset publisher', () => { + it('publishes the reviewed action into the existing outfit', async () => { + const updates: Character[] = [] + const publisher = ( + exportFeature as unknown as ExportFeatureModule + ).createCharacterAssetPublisher({ + async update(character) { + updates.push(structuredClone(character)) + return structuredClone(character) + }, + }) + + const published = await publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: completeAnimationFixture(), + }) + + expect(updates).toHaveLength(1) + expect(published).toMatchObject({ + description: '人工维护的角色描述', + referenceImageUrl: 'https://assets.windup.test/current-master.png', + }) + expect(published.outfits[0]).toMatchObject({ + id: 'outfit-default', + previewUrl: 'https://assets.windup.test/outfit.png', + }) + expect(published.outfits[0]?.actions).toEqual([ + expect.objectContaining({ id: 'idle', type: 'idle' }), + { + id: 'action-walk', + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + loop: true, + fps: 12, + frameCount: 2, + frames: [ + { index: 0, imageUrl: 'https://assets.windup.test/walk-01.png', durationMs: null }, + { index: 1, imageUrl: 'https://assets.windup.test/walk-02.png', durationMs: null }, + ], + }, + ]) + }) + + it('replaces the same stable action instead of duplicating it on retry', async () => { + const publisher = ( + exportFeature as unknown as ExportFeatureModule + ).createCharacterAssetPublisher({ + async update(character) { + return structuredClone(character) + }, + }) + const input = { + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: completeAnimationFixture(), + } + + const first = await publisher.publishReviewedAction({ character: characterFixture(), ...input }) + const retried = await publisher.publishReviewedAction({ character: first, ...input }) + + expect(retried.outfits[0]?.actions.map((action) => action.id)).toEqual(['idle', 'action-walk']) + }) + + it('rejects a completed task from another project', async () => { + const publisher = createRejectingPublisher() + + await expect( + publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: { ...completeAnimationFixture(), projectId: 'another-project' }, + }), + ).rejects.toThrow('Generation 与当前 WorkflowRun 不匹配') + }) + + it('rejects a task whose type is not complete_animation', async () => { + const publisher = createRejectingPublisher() + const wrongType = { + ...completeAnimationFixture(), + type: 'character_template', + } as unknown as Generation + + await expect( + publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: wrongType, + }), + ).rejects.toThrow('完整动画生成结果不可发布') + }) +}) + +function createRejectingPublisher() { + return (exportFeature as unknown as ExportFeatureModule).createCharacterAssetPublisher({ + async update() { + throw new Error('不应写入 Character') + }, + }) +} + +function characterFixture(): Character { + return { + id: '51', + projectId: '42', + workflowRunId: '120', + name: '骑士', + description: '人工维护的角色描述', + referenceImageUrl: 'https://assets.windup.test/current-master.png', + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: null, + previewUrl: 'https://assets.windup.test/outfit.png', + actions: [ + { + id: 'idle', + outfitId: 'outfit-default', + name: '待机', + type: 'idle', + loop: true, + fps: 8, + frameCount: 1, + frames: [ + { + index: 0, + imageUrl: 'https://assets.windup.test/idle-01.png', + durationMs: null, + }, + ], + }, + ], + }, + ], + } +} + +function workflowFixture(): WorkflowRun { + return { + id: '120', + projectId: '42', + version: 7, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '戴红围巾的像素骑士', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: 'https://assets.windup.test/master.png', + }, + { + id: 'action-walk', + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-template'], + generations: [], + error: null, + input: { + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + prompt: '轻快地向前行走', + fps: 12, + }, + selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png', + }, + { + id: 'action-walk:action-generation-method', + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-walk'], + generations: [], + error: null, + method: 'video-cropping', + }, + { + id: 'action-walk:action-full-frame', + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-walk:action-generation-method'], + generations: [{ taskId: 'generation-walk', role: 'complete_animation' }], + error: null, + }, + { + id: 'action-walk:review', + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: ['action-walk:action-full-frame'], + generations: [], + error: null, + }, + ], + } +} + +function completeAnimationFixture(): Generation<'complete_animation'> { + return { + id: 'generation-walk', + projectId: '42', + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + frames: [ + { url: 'https://assets.windup.test/walk-01.png' }, + { url: 'https://assets.windup.test/walk-02.png' }, + ], + }, + } +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts index adfc5aad..706e80b9 100644 --- a/frontend/src/features/export/index.ts +++ b/frontend/src/features/export/index.ts @@ -1,6 +1,115 @@ -/** 把确认后的造型与动作导出到资产库。 */ -export interface ExportProps { - runId: string - characterId: string - outfitId: string +import type { + Action, + Character, + CharacterApis, + Generation, + WorkflowNode, + WorkflowRun, +} from '@/entities' + +export interface PublishReviewedActionInput { + character: Character + workflow: WorkflowRun + reviewNodeId: string + generation: Generation +} + +export interface CharacterAssetPublisher { + publishReviewedAction(input: PublishReviewedActionInput): Promise +} + +/** + * 审核通过前把完整动画幂等写入 Character 资产树。Action ID 使用首帧节点 ID, + * 因而发布成功但 WorkflowRun 保存失败时可以安全重试,不会追加重复动作。 + */ +export function createCharacterAssetPublisher( + characterApis: Pick, +): CharacterAssetPublisher { + return { + async publishReviewedAction({ character, workflow, reviewNodeId, generation }) { + if (character.workflowRunId !== workflow.id || character.projectId !== workflow.projectId) { + throw new Error('Character 与当前 WorkflowRun 不匹配') + } + + const reviewNode = findNode(workflow, reviewNodeId, 'review') + if (reviewNode.status !== 'active' || reviewNode.phase !== 'reviewing') { + throw new Error('只能发布正在审核的动作') + } + const fullFrameNode = findSingleDependency(workflow, reviewNode, 'action-full-frame') + if (fullFrameNode.status !== 'passed' || fullFrameNode.phase !== 'completed') { + throw new Error('完整动画尚未完成') + } + if (generation.projectId !== workflow.projectId) { + throw new Error('Generation 与当前 WorkflowRun 不匹配') + } + if ( + generation.id !== + fullFrameNode.generations.find((item) => item.role === 'complete_animation')?.taskId || + generation.type !== 'complete_animation' || + generation.status !== 'completed' || + generation.result?.type !== 'complete_animation' || + generation.result.frames.length === 0 + ) { + throw new Error('完整动画生成结果不可发布') + } + + const methodNode = findSingleDependency(workflow, fullFrameNode, 'action-generation-method') + const firstFrameNode = findSingleDependency(workflow, methodNode, 'action-first-frame') + const templateNode = findSingleDependency(workflow, firstFrameNode, 'character-template') + if (!templateNode.selectedImageUrl) throw new Error('角色母版尚未确认') + + const outfitIndex = character.outfits.findIndex( + (outfit) => outfit.id === firstFrameNode.input.outfitId, + ) + if (outfitIndex < 0) throw new Error('动作所属造型不存在') + + const action: Action = { + id: firstFrameNode.id, + outfitId: firstFrameNode.input.outfitId, + name: firstFrameNode.input.name, + type: firstFrameNode.input.type, + loop: firstFrameNode.input.type === 'idle' || firstFrameNode.input.type === 'walk', + fps: firstFrameNode.input.fps, + frameCount: generation.result.frames.length, + frames: generation.result.frames.map((frame, index) => ({ + index, + imageUrl: frame.url, + durationMs: null, + })), + } + const targetOutfit = character.outfits[outfitIndex]! + const actionIndex = targetOutfit.actions.findIndex((item) => item.id === action.id) + const actions = [...targetOutfit.actions] + if (actionIndex >= 0) actions[actionIndex] = action + else actions.push(action) + + const outfits = [...character.outfits] + outfits[outfitIndex] = { ...targetOutfit, actions } + return characterApis.update({ + ...character, + outfits, + }) + }, + } +} + +function findNode( + workflow: WorkflowRun, + nodeId: string, + type: TType, +): Extract { + const node = workflow.nodes.find((candidate) => candidate.id === nodeId) + if (!node || node.type !== type) throw new Error(`目标节点不是 ${type}`) + return node as Extract +} + +function findSingleDependency( + workflow: WorkflowRun, + node: WorkflowNode, + type: TType, +): Extract { + if (node.dependsOnNodeIds.length !== 1) { + throw new Error(`${node.id} 必须且只能依赖一个节点`) + } + return findNode(workflow, node.dependsOnNodeIds[0]!, type) } diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts index 20157522..fe628fdd 100644 --- a/frontend/src/features/generation/index.ts +++ b/frontend/src/features/generation/index.ts @@ -3,8 +3,8 @@ export interface GenerationProps { runId: string /** * 目标动作,生成母版等非动作任务可以省略。 - * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, - * 不能脱离 run 单独使用这个字段。 + * 动作 ID 只在造型内唯一;调用方需用 runId 找到绑定 Character,再从 + * WorkflowRun 动作节点的 input.outfitId 确定造型,不能脱离两者单独使用此字段。 */ actionId?: string } diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 713d76c2..64cf347b 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -464,16 +464,20 @@ describe('WorkflowController', () => { it('提交角色设定后在母版节点记录任务并进入候选选择', async () => { const { controller, workflow, generation, asyncErrors } = createController() - await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacterTemplate('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + input: { prompt: '戴红围巾的像素骑士', referenceMedia: [] }, + }) - expect(generation.apis.create).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'character_template', - prompt: '像素骑士', - spriteWidth: 64, - spriteHeight: 64, - }), - ) + expect(generation.apis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: '1', + prompt: '戴红围巾的像素骑士', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }) expect(workflow.getSaved().nodes).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'setup-1', status: 'passed', phase: 'completed' }), diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 251f85a8..a5e3ff91 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -15,6 +15,7 @@ import type { MediaReference, ReviewWorkflowNode, WorkflowActionInput, + WorkflowCharacterInput, WorkflowGenerationRef, WorkflowGenerationRole, WorkflowNode, @@ -35,6 +36,8 @@ export interface AddActionInput { export interface GenerateCharacterTemplateOptions { spriteWidth: number spriteHeight: number + /** 手动编辑器提交时覆盖 configuring 节点的初始输入;节点通过后不再改写。 */ + input?: WorkflowCharacterInput } export interface GenerateActionOptions { @@ -315,8 +318,20 @@ export function createWorkflowController({ if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') { throw new Error('角色设定节点当前不能提交') } + const input = options.input + ? { + prompt: nonEmpty(options.input.prompt, '角色描述'), + referenceMedia: [...options.input.referenceMedia], + } + : setupNode.input return unlockReadyNodes( - replaceNode(run, { ...setupNode, status: 'passed', phase: 'completed', error: null }), + replaceNode(run, { + ...setupNode, + input, + status: 'passed', + phase: 'completed', + error: null, + }), ) }) const templateNode = findSingleDependentNode(advanced, nodeId, 'character-template') @@ -329,7 +344,8 @@ export function createWorkflowController({ projectId: run.projectId, prompt: setupNode.input.prompt, referenceMedia: setupNode.input.referenceMedia, - ...options, + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, } return input }) diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx index e2b81e3f..0d0806eb 100644 --- a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx +++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx @@ -16,6 +16,7 @@ function frame(index: number, imageUrl: string) { const character: Character = { id: '51', projectId: '42', + workflowRunId: 'workflow-run-51', name: '轻装信使', description: null, referenceImageUrl: null, diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts index 377a7a5c..b762dd33 100644 --- a/frontend/src/pages/playtest/workbench/model.test.ts +++ b/frontend/src/pages/playtest/workbench/model.test.ts @@ -7,6 +7,7 @@ import { createPlaytestModel } from './model' const character: Character = { id: '51', projectId: '42', + workflowRunId: 'workflow-run-51', name: '轻装信使', description: null, referenceImageUrl: null, diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index bb752c0d..40c5f83e 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -1,39 +1,1015 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter, Route, Routes } from 'react-router' +import type { ReactNode } from 'react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Link, MemoryRouter, Route, Routes } from 'react-router' +import type { + Character, + CharacterTemplateWorkflowNode, + Generation, + GenerationApis, + Project, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller' import { WorkflowEditorPage } from './index' +import type { WorkflowEditorSession } from './runtime' +const { defaultSessionLoader, flowProps, fitView } = vi.hoisted(() => ({ + defaultSessionLoader: vi.fn(), + flowProps: { current: null as Record | null }, + fitView: vi.fn(), +})) + +vi.mock('./runtime', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createDefaultRealWorkflowEditorSession: defaultSessionLoader, + } +}) + +vi.mock('@xyflow/react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Controls: () => null, + Handle: () => null, + ReactFlow: ({ nodes, children, ...props }: TestFlowProps) => { + flowProps.current = { nodes, ...props } + return ( +
+ {nodes.map((node) => ( +
+ {node.data.content} +
+ ))} + {children} +
+ ) + }, + useReactFlow: () => ({ fitView }), + } +}) + +interface TestCanvasNode { + id: string + deletable?: boolean + draggable?: boolean + dragHandle?: string + data: { title: string; content: ReactNode } +} + +interface TestFlowProps extends Record { + nodes: TestCanvasNode[] + children?: ReactNode +} + +beforeEach(() => { + defaultSessionLoader.mockReset() + fitView.mockClear() + flowProps.current = null + window.history.replaceState({}, '', '/') +}) afterEach(cleanup) -describe('WorkflowEditorPage', () => { - it('按六节点顺序展示资产生成方式选择', () => { - const onSelect = vi.fn() +describe('WorkflowEditorPage real runtime boundary', () => { + it('默认路由只恢复真实 WorkflowRun 会话', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + window.history.replaceState({}, '', '/workflow-editor/42') + + renderEditor('/workflow-editor/42') + + expect(await screen.findByText('真实 WorkflowRun 接口')).toBeTruthy() + expect(defaultSessionLoader).toHaveBeenCalledWith('42') + }) + + it('在角色设定卡片提交描述后由 Controller 持久化并固定输入', async () => { + const session = createSession() + defaultSessionLoader.mockResolvedValue(session) + + renderEditor('/workflow-editor/42') + + const promptInput = await screen.findByRole('textbox', { name: '角色描述' }) + expect(screen.queryByRole('textbox', { name: /角色名称/ })).toBeNull() + fireEvent.change(promptInput, { target: { value: '戴红围巾的短发少年冒险家' } }) + fireEvent.click(screen.getByRole('button', { name: '生成角色候选' })) + + await waitFor(() => + expect(session.controller.getWorkflow().nodes[0]).toMatchObject({ + status: 'passed', + phase: 'completed', + input: { prompt: '戴红围巾的短发少年冒险家', referenceMedia: [] }, + }), + ) + await waitFor(() => expect(screen.queryByRole('textbox', { name: '角色描述' })).toBeNull()) + }) + + it('真实 Generation 尚未实现时展示接口错误,不回退到演示候选', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + expect(screen.queryByRole('button', { name: /选择角色候选/ })).toBeNull() + }) + + it('切换 WorkflowRun 时清空上一条任务的临时动作菜单', async () => { + defaultSessionLoader + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('42'))) + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43'))) + render( - + + 下一条 WorkflowRun - } - /> + } /> , ) - expect(screen.getAllByRole('listitem')).toHaveLength(6) - fireEvent.click(screen.getByRole('button', { name: /视频裁剪/ })) - fireEvent.click(screen.getByRole('button', { name: /3D 转 2D/ })) - expect(onSelect.mock.calls).toEqual([['video-cropping'], ['3d-to-2d']]) + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + expect(screen.getByRole('button', { name: '生成动作 ›' })).toBeTruthy() + + fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' })) + await waitFor(() => + expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe( + 'https://assets.windup.test/43.png', + ), + ) + + expect(screen.queryByRole('button', { name: '生成动作 ›' })).toBeNull() }) - it('没有 Controller 装配时不在页面内伪造选择', () => { + it('切换 WorkflowRun 后忽略上一条任务迟到的命令错误', async () => { + const pendingGeneration = deferred() + const createGeneration = vi.fn(() => pendingGeneration.promise) + defaultSessionLoader + .mockResolvedValueOnce( + createSession(workflowFixture(), { + generationApis: generationApisFixture({ create: createGeneration }), + }), + ) + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43'))) + render( - - + + 下一条 WorkflowRun + + } /> + , ) - expect(screen.getByRole('button', { name: /视频裁剪/ })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: /3D 转 2D/ })).toHaveProperty('disabled', true) + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' })) + await waitFor(() => + expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe( + 'https://assets.windup.test/43.png', + ), + ) + + await act(async () => { + pendingGeneration.reject(new Error('旧 WorkflowRun 的迟到错误')) + await pendingGeneration.promise.catch(() => undefined) + }) + + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('只采用同一 WorkflowRun 内最后一次 Generation 读取结果', async () => { + const oldRead = deferred() + const latestRead = deferred() + defaultSessionLoader.mockResolvedValue(createGenerationRaceSession(oldRead, latestRead)) + + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + await act(async () => { + latestRead.resolve(characterGeneration('new')) + await latestRead.promise + }) + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ) + + await act(async () => { + oldRead.resolve(characterGeneration('old')) + await oldRead.promise + }) + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ) + }) + + it('从 WorkflowRun 绑定角色中明确选择造型,并只提交后端支持的动作类型', async () => { + const session = createSession(completedTemplateWorkflow('42'), { + character: characterFixture(), + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + fireEvent.click(screen.getByRole('button', { name: '生成动作 ›' })) + fireEvent.click(screen.getByRole('button', { name: '选择造型 夜行装' })) + + expect(screen.queryByRole('button', { name: /Jump/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Attack 攻击/ })) + + await waitFor(() => + expect(session.controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'action-first-frame', + // 落库的动作名是中文短名,不含菜单里的英文前缀。 + input: expect.objectContaining({ outfitId: 'night', type: 'attack', name: '攻击' }), + }), + ]), + ), + ) + }) + + it('发布成功但审核保存失败时仍显式刷新 Character', async () => { + const publishReviewedAction = vi.fn(async () => characterFixture()) + const session = createSession(reviewingActionWorkflow(), { + character: { ...characterFixture(), outfits: [] }, + generationApis: generationApisFixture({ + get: vi.fn().mockResolvedValue(completeAnimationGeneration()), + }), + publishReviewedAction, + }) + vi.spyOn(session.controller, 'approveReview').mockRejectedValue(new Error('审核保存失败')) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '审核通过' })) + + expect((await screen.findByRole('alert')).textContent).toContain('审核保存失败') + expect(publishReviewedAction).toHaveBeenCalledWith('action-walk:review') + fireEvent.click(screen.getByRole('button', { name: '添加动作分支' })) + expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe( + false, + ) + }) + + it('同一节点挂多个生成任务时按角色分别读取,后写入的不覆盖前一个', async () => { + const workflow = selectingTemplateWorkflow(3, 'template-task') + workflow.nodes[1] = { + ...(workflow.nodes[1] as CharacterTemplateWorkflowNode), + generations: [ + { taskId: 'template-task', role: 'character_template' }, + { taskId: 'animation-task', role: 'complete_animation' }, + ], + } + defaultSessionLoader.mockResolvedValue( + createSession(workflow, { + generationApis: generationApisFixture({ + get: vi.fn(async (_projectId: string, taskId: string) => + taskId === 'template-task' + ? characterGeneration('template') + : completeAnimationGeneration(), + ) as GenerationApis['get'], + }), + }), + ) + + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('img', { name: '角色候选 1' })).getAttribute('src')).toContain( + '/template.png', + ) + }) + + it('已删除的节点不再读取生成结果', async () => { + const workflow = selectingTemplateWorkflow(3, 'template-task') + workflow.nodes[1] = { + ...(workflow.nodes[1] as CharacterTemplateWorkflowNode), + deletedAt: '2026-08-11T00:00:00.000Z', + } + const get = vi.fn(async () => characterGeneration('template')) + defaultSessionLoader.mockResolvedValue( + createSession(workflow, { + generationApis: generationApisFixture({ get: get as GenerationApis['get'] }), + }), + ) + + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(1)) + + expect(get).not.toHaveBeenCalled() + }) + + it('WorkflowRun 推进但节点集合不变时不重置画布视角', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + await waitFor(() => expect(fitView).toHaveBeenCalled()) + + const afterFirstFit = fitView.mock.calls.length + act(() => controlled.emit(selectingTemplateWorkflow(9, 'another-task'))) + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(fitView.mock.calls.length).toBe(afterFirstFit) + }) + + it('新增动作分支后重新取景,让新节点进入视野', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + await waitFor(() => expect(fitView).toHaveBeenCalled()) + + const afterFirstFit = fitView.mock.calls.length + act(() => controlled.emit(reviewingActionWorkflow())) + await waitFor(() => expect(fitView.mock.calls.length).toBeGreaterThan(afterFirstFit)) + }) + + it('一条动作分支执行命令时不阻塞其他分支的操作', async () => { + const pendingPublish = deferred() + defaultSessionLoader.mockResolvedValue( + createSession(reviewingActionWorkflow(), { + character: characterFixture(), + publishReviewedAction: () => pendingPublish.promise, + }), + ) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '审核通过' })) + expect((screen.getByRole('button', { name: '审核通过' }) as HTMLButtonElement).disabled).toBe( + true, + ) + + fireEvent.click(screen.getByRole('button', { name: '添加动作分支' })) + expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe( + false, + ) + + await act(async () => { + pendingPublish.resolve(characterFixture()) + await pendingPublish.promise + }) + }) + + it('两条分支同时执行命令时,先起的那条不会因为后起的而解锁', async () => { + // 并行分支各自持锁:B 开始执行不能把 A 的锁顶掉,否则 A 的按钮会重新可点, + // 用户再点一次就是对同一个动作重复发布 + 重复审核。 + const pendingA = deferred() + const pendingB = deferred() + const publishReviewedAction = vi.fn((reviewNodeId: string) => + reviewNodeId === 'action-a:review' ? pendingA.promise : pendingB.promise, + ) + defaultSessionLoader.mockResolvedValue( + createSession(reviewingActionWorkflow('action-a', 'action-b'), { + character: characterFixture(), + publishReviewedAction, + }), + ) + renderEditor('/workflow-editor/42') + + const approveButtons = await screen.findAllByRole('button', { name: '审核通过' }) + expect(approveButtons).toHaveLength(2) + const [approveA, approveB] = approveButtons as [HTMLButtonElement, HTMLButtonElement] + + fireEvent.click(approveA) + expect(approveA.disabled).toBe(true) + + fireEvent.click(approveB) + await waitFor(() => expect(publishReviewedAction).toHaveBeenCalledTimes(2)) + + // A 的请求仍未返回,它必须还锁着。 + expect(approveA.disabled).toBe(true) + expect(approveB.disabled).toBe(true) + + fireEvent.click(approveA) + expect(publishReviewedAction).toHaveBeenCalledTimes(2) + + await act(async () => { + pendingA.resolve(characterFixture()) + pendingB.resolve(characterFixture()) + await Promise.all([pendingA.promise, pendingB.promise]) + }) + }) + + it('已经到终态的生成结果只读取一次,后续推进复用缓存', async () => { + // WorkflowRun 每推进一步都会 emit,若每次都重拉全部结果,一条多分支的流程 + // 点一次按钮就是十几个 GET。终态结果不会再变,没有重读的理由。 + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + await waitFor(() => expect(screen.getByRole('img', { name: '角色候选 1' })).toBeTruthy()) + const reads = controlled.session.controller.getGeneration as ReturnType + const afterFirstRead = reads.mock.calls.length + expect(afterFirstRead).toBeGreaterThan(0) + + act(() => controlled.emit(selectingTemplateWorkflow(7, 'old-task'))) + act(() => controlled.emit(selectingTemplateWorkflow(8, 'old-task'))) + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + expect(reads.mock.calls.length).toBe(afterFirstRead) + }) + + it('未到终态的生成任务每次推进都重新读取', async () => { + const controlled = createRestartSelectionSession({ status: 'running' }) + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + const reads = controlled.session.controller.getGeneration as ReturnType + await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(0)) + const afterFirstRead = reads.mock.calls.length + + act(() => controlled.emit(selectingTemplateWorkflow(7, 'old-task'))) + await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(afterFirstRead)) + }) + + it('失败节点可以从当前节点重做', async () => { + const session = createSession(failedTemplateWorkflow()) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + + expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy() + expect(session.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'ready', + error: null, + }) + }) + + it('节点重做后不会沿用上一轮 Generation 的候选选择', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(false) + + act(() => controlled.emit(failedTemplateWorkflow())) + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + act(() => controlled.emit(selectingTemplateWorkflow(6, 'new-task'))) + await waitFor(() => + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ), + ) + + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('Generation 候选集合变化时使旧选择失效', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + act(() => controlled.emit(selectingTemplateWorkflow(4, 'new-task'))) + await waitFor(() => + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ), + ) + + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('生成接口提交失败后仍保留角色候选重试入口', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + expect(screen.getByRole('button', { name: '生成角色候选' })).toBeTruthy() + }) + + it('恢复候选读取失败时展示错误并允许重新读取', async () => { + const generationApis = generationApisFixture() + defaultSessionLoader.mockResolvedValue( + createSession(selectingTemplateWorkflow(3, 'existing-task'), { generationApis }), + ) + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + fireEvent.click(screen.getByRole('button', { name: '重试读取生成结果' })) + + await waitFor(() => expect(generationApis.get).toHaveBeenCalledTimes(2)) + }) + + it('恢复生成中节点失败后仍可从该节点重做', async () => { + const session = createSession(generatingTemplateWorkflow()) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + + expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy() + expect(session.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'ready', + error: null, + }) + }) + + it('展示 Controller 的异步错误订阅', async () => { + let reportError: ((error: Error) => void) | null = null + const session = createSession() + session.subscribeErrors = vi.fn((listener) => { + reportError = listener + return () => undefined + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + await screen.findByText('真实 WorkflowRun 接口') + + act(() => reportError?.(new Error('异步保存失败'))) + + expect(screen.getByRole('alert').textContent).toContain('异步保存失败') + }) + + it('把 React Flow 限定为系统节点的拖动画布,不允许自由连线、重连或删除', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + expect(latestFlowProps()).toMatchObject({ + nodesConnectable: false, + edgesReconnectable: false, + deleteKeyCode: null, + }) + expect('onConnect' in latestFlowProps()).toBe(false) + expect(latestFlowProps().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + deletable: false, + draggable: true, + dragHandle: '.workflow-card__handle', + }), + ]), + ) + expect(latestFlowProps().edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + selectable: false, + deletable: false, + // 未确认的连线画成流动虚线,样式由 workflow-edge--flowing 承担。 + className: 'workflow-edge--flowing', + }), + ]), + ) + }) + + it('没有 runId 时不创建替代 WorkflowRun', () => { + render( + + + } /> + + , + ) + + expect(screen.getByRole('heading', { name: '工作流编辑器' })).toBeTruthy() + expect(screen.getByText('需要从已有 WorkflowRun 进入')).toBeTruthy() + expect(defaultSessionLoader).not.toHaveBeenCalled() }) }) + +function renderEditor(path: string) { + return render( + + + } /> + + , + ) +} + +interface SessionFixtureOptions { + character?: Character | null + generationApis?: GenerationApis + publishReviewedAction?(reviewNodeId: string): Promise +} + +function createSession( + initialWorkflow = workflowFixture(), + options: SessionFixtureOptions = {}, +): WorkflowEditorSession { + let workflow = structuredClone(initialWorkflow) + const workflowRunApis: WorkflowRunApis = { + async create() { + return structuredClone(workflow) + }, + async get() { + return structuredClone(workflow) + }, + async update(next) { + workflow = { ...structuredClone(next), version: workflow.version + 1 } + return structuredClone(workflow) + }, + async remove() {}, + } + const generationApis = options.generationApis ?? generationApisFixture() + const controller = createWorkflowController({ + workflow, + workflowRunApis, + generationApis, + onAsyncError: vi.fn(), + }) + + return { + controller, + project: projectFixture(), + character: options.character ?? null, + publishReviewedAction: + options.publishReviewedAction ?? (() => Promise.reject(new Error('资产发布未装配'))), + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + } +} + +function generationApisFixture(overrides: Partial = {}): GenerationApis { + return { + create: vi.fn(() => + Promise.reject(new Error('Generation 后端尚未实现')), + ) as GenerationApis['create'], + get: vi.fn(() => Promise.reject(new Error('Generation 后端尚未实现'))), + subscribe: vi.fn(() => () => undefined), + ...overrides, + } +} + +function latestFlowProps() { + if (!flowProps.current) throw new Error('React Flow 尚未渲染') + return flowProps.current as { + nodes: TestCanvasNode[] + edges: Array> + nodesConnectable?: boolean + edgesReconnectable?: boolean + deleteKeyCode?: null + } & Record +} + +function workflowFixture(): WorkflowRun { + return { + id: '42', + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'active', + phase: 'configuring', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '短发少年冒险家', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: null, + }, + ], + } +} + +function completedTemplateWorkflow(id: string): WorkflowRun { + return { + id, + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '短发少年冒险家', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: `https://assets.windup.test/${id}.png`, + }, + ], + } +} + +function reviewingActionWorkflow(...actionIds: string[]): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.version = 7 + for (const actionId of actionIds.length > 0 ? actionIds : ['action-walk']) { + workflow.nodes.push( + { + id: actionId, + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-template'], + generations: [], + error: null, + input: { + outfitId: 'day', + name: '行走', + type: 'walk', + prompt: null, + fps: 12, + }, + selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png', + }, + { + id: `${actionId}:method`, + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [actionId], + generations: [], + error: null, + method: 'video-cropping', + }, + { + id: `${actionId}:full-frame`, + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [`${actionId}:method`], + generations: [{ taskId: `generation-${actionId}`, role: 'complete_animation' }], + error: null, + }, + { + id: `${actionId}:review`, + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: [`${actionId}:full-frame`], + generations: [], + error: null, + }, + ) + } + return workflow +} + +function failedTemplateWorkflow(): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'failed-task', role: 'character_template' }], + error: '候选生成失败', + selectedImageUrl: null, + } + return workflow +} + +function selectingTemplateWorkflow(version: number, taskId: string): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.version = version + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'selecting', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId, role: 'character_template' }], + error: null, + selectedImageUrl: null, + } + return workflow +} + +function generatingTemplateWorkflow(): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'generating', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'in-flight-task', role: 'character_template' }], + error: null, + selectedImageUrl: null, + } + return workflow +} + +function createGenerationRaceSession( + oldRead: Deferred, + latestRead: Deferred, +): WorkflowEditorSession { + const first = selectingTemplateWorkflow(3, 'old-task') + const latest = selectingTemplateWorkflow(4, 'new-task') + let listener: ((workflow: WorkflowRun) => void) | null = null + let readCount = 0 + const controller = { + getWorkflow: () => structuredClone(latest), + subscribe(nextListener: (workflow: WorkflowRun) => void) { + listener = nextListener + nextListener(structuredClone(first)) + return () => { + listener = null + } + }, + async resume() { + listener?.(structuredClone(latest)) + }, + getGeneration: vi.fn(() => { + readCount += 1 + return readCount === 1 ? oldRead.promise : latestRead.promise + }), + dispose: vi.fn(), + } as unknown as WorkflowController + + return { + controller, + project: projectFixture(), + character: null, + publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + } +} + +function createRestartSelectionSession(options: { status?: Generation['status'] } = {}): { + session: WorkflowEditorSession + emit(workflow: WorkflowRun): void +} { + let current = selectingTemplateWorkflow(3, 'old-task') + let listener: ((workflow: WorkflowRun) => void) | null = null + const emit = (workflow: WorkflowRun) => { + current = structuredClone(workflow) + listener?.(structuredClone(current)) + } + const controller = { + getWorkflow: () => structuredClone(current), + subscribe(nextListener: (workflow: WorkflowRun) => void) { + listener = nextListener + nextListener(structuredClone(current)) + return () => { + listener = null + } + }, + resume: vi.fn(async () => undefined), + getGeneration: vi.fn(async () => { + const taskId = current.nodes[1]?.generations[0]?.taskId + if (!taskId) return null + const generation = characterGeneration(taskId.startsWith('new') ? 'new' : 'old') + return options.status ? { ...generation, status: options.status } : generation + }), + restartFromNode: vi.fn(async () => { + const ready = completedTemplateWorkflow('42') + ready.version = current.version + 1 + ready.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'ready', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: null, + } + emit(ready) + }), + confirmCharacterTemplate: vi.fn(async () => undefined), + dispose: vi.fn(), + } as unknown as WorkflowController + + return { + session: { + controller, + project: projectFixture(), + character: null, + publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + }, + emit, + } +} + +function characterGeneration(label: string): Generation { + return { + id: `${label}-task`, + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: `https://assets.windup.test/${label}.png` }], + }, + error: null, + } +} + +function completeAnimationGeneration(): Generation<'complete_animation'> { + return { + id: 'generation-walk', + projectId: '1', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: [ + { url: 'https://assets.windup.test/walk-01.png' }, + { url: 'https://assets.windup.test/walk-02.png' }, + ], + }, + error: null, + } +} + +function characterFixture(): Character { + return { + id: '9', + projectId: '1', + workflowRunId: '42', + name: '正式角色', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'day', + characterId: '9', + name: '常态装', + description: null, + previewUrl: null, + actions: [], + }, + { + id: 'night', + characterId: '9', + name: '夜行装', + description: null, + previewUrl: null, + actions: [], + }, + ], + } +} + +interface Deferred { + promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function projectFixture(): Project { + return { + id: '1', + ownerId: '7', + workflowId: null, + name: '正式项目', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 64, height: 64 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-10T00:00:00.000Z', + updatedAt: '2026-08-10T00:00:00.000Z', + } +} diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index ef527e41..cab7bd7a 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -1,90 +1,1150 @@ +import { + Controls, + Handle, + Position, + ReactFlow, + applyNodeChanges, + type Edge, + type Node, + type NodeChange, + type NodeProps, + useReactFlow, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useParams } from 'react-router' -import type { ActionGenerationMethod } from '@/entities' -import { PageContainer } from '@/shared/ui' - -const WORKFLOW_CARDS = [ - { index: '01', title: '角色设定', detail: '输入身份、外观与参考图' }, - { index: '02', title: '角色母版', detail: '生成并确认角色母版' }, - { index: '03', title: '动作首帧', detail: '生成并确认动作起始帧' }, - { index: '04', title: '资产生成方式', detail: '选择视频裁剪或 3D 转 2D' }, - { index: '05', title: '完整动画', detail: '按所选路线生成 32 帧动画' }, - { index: '06', title: '审核', detail: '核验结果并进入 Playtest' }, -] as const +import { + CHARACTER_PERSPECTIVE, + DIRECTIONAL_MOVEMENT, + type ActionFirstFrameWorkflowNode, + type ActionFullFrameWorkflowNode, + type ActionGenerationMethodWorkflowNode, + type Character, + type CharacterSetupWorkflowNode, + type CharacterTemplateWorkflowNode, + type Generation, + type Project, + type ReviewWorkflowNode, + type WorkflowGenerationRole, + type WorkflowNode, + type WorkflowRun, +} from '@/entities' +import type { WorkflowController } from '@/features/workflow-controller' +import { createDefaultRealWorkflowEditorSession, type WorkflowEditorSession } from './runtime' +import './workflow-editor.css' export interface WorkflowEditorPageProps { - selectedGenerationMethod?: ActionGenerationMethod | null - onSelectGenerationMethod?: (method: ActionGenerationMethod) => void + loadSession?: (runId: string) => Promise } -/** 工作流画布骨架;真实状态只来自 WorkflowRun,不在页面内伪造节点推进。 */ -export function WorkflowEditorPage({ - selectedGenerationMethod = null, - onSelectGenerationMethod, -}: WorkflowEditorPageProps = {}) { +type WorkflowCardData = { + title: string + eyebrow: string + status: WorkflowNode['status'] + content: ReactNode +} + +type WorkflowCardNode = Node +type ActionMenuLevel = 'root' | 'outfits' | 'actions' + +/** + * 菜单里的预设动作。label 只用于展示,name 是落进 WorkflowRun 的动作名—— + * 两者分开写,改菜单文案不会连带改掉已经落库的数据。 + */ +const ACTION_PRESETS = [ + { + type: 'idle', + label: 'Idle 待机', + name: '待机', + prompt: '平稳呼吸,重心轻微起伏', + }, + { type: 'walk', label: 'Walk 行走', name: '行走', prompt: '轻快地向前行走' }, + { + type: 'attack', + label: 'Attack 攻击', + name: '攻击', + prompt: '蓄力后向前攻击并回到准备姿态', + }, +] as const + +const ACTION_PRESET_HINT = '预设动作 · 逐帧生成' + +/** 角色设定与身份母版为所有动作分支共用,归在这条虚拟分支下。 */ +const SHARED_BRANCH = 'shared' + +/* + 卡片内部复用三次以上的样式串。原来靠 .workflow-card button 这类后代选择器统一施加, + 搬成工具类后写在这里,好处是能看见哪些元素共用同一套外观,而不是被选择器隐式波及。 + nodrag/nopan/nowheel 是 React Flow 的约定类:让卡片内的交互不被画布手势吞掉。 +*/ +const CARD_STACK = 'grid gap-[17px] nodrag nopan nowheel' + +const CARD_BUTTON = + 'min-h-[42px] rounded-lg border border-[#34533d] bg-[#34533d] px-3 py-[9px] text-[11px] ' + + 'font-[750] text-[#edf4ee] enabled:hover:border-[#263f2e] enabled:hover:bg-[#263f2e] ' + + 'aria-pressed:border-[#263f2e] aria-pressed:bg-[#263f2e] disabled:cursor-not-allowed ' + + 'disabled:border-[#e2e5e1] disabled:bg-[#e2e5e1] disabled:text-[#969d97]' + +/** 缩略图按钮:沿用卡片按钮的尺寸约定,但换成浅底,让图片自己当主角。 */ +const THUMB_BUTTON = + 'min-h-[42px] rounded-lg border border-[var(--editor-line)] bg-[#fbfcfb] p-1 ' + + 'aria-pressed:border-[var(--editor-ink)] aria-pressed:bg-white ' + + 'aria-pressed:shadow-[0_0_0_2px_rgb(31_35_41_/_12%)] disabled:cursor-not-allowed' + +const THUMB_IMAGE = 'block aspect-square w-full rounded-lg object-cover' + +/** 已确认的母版/首帧:像素资产按原样放大,不做平滑。 */ +const MASTER_IMAGE = + 'block aspect-square w-full rounded-xl border border-[var(--editor-line)] bg-[#f6f7f6] ' + + 'object-cover [image-rendering:pixelated]' + +const CARD_SUMMARY = + 'm-0 rounded-[10px] border border-[var(--editor-line)] bg-[#f6f7f6] px-3 py-2.5 ' + + 'text-[11px] leading-[1.6] text-[var(--editor-muted)]' + +const CARD_TEXT = 'm-0 text-[11px] leading-[1.6] text-[var(--editor-muted)]' + +/** 加号菜单里的条目:撑满菜单宽度的两行文字,跟卡片主按钮完全不同。 */ +const MENU_ITEM = + 'flex min-h-0 cursor-pointer flex-col gap-0.5 border-0 px-3 py-[9px] text-left ' + + 'text-[var(--editor-ink)] not-first:border-t not-first:border-t-[rgb(31_35_41_/_6%)] ' + + 'enabled:hover:bg-[#f2f4f2] disabled:cursor-not-allowed disabled:opacity-45' + +/** + * 菜单里的头一条:返回上一级,或 root 层的主入口。比其余条目弱一档, + * 原样式靠 :first-child 选择器实现,这里改成显式挂类,位置换了也不会失灵。 + */ +const MENU_ITEM_LEAD = 'font-medium text-[var(--editor-muted)]' + +const MENU_ITEM_TITLE = 'text-xs font-semibold' +const MENU_ITEM_HINT = 'text-[10px] text-[var(--editor-muted)]' + +const nodeTypes = { 'workflow-card': WorkflowCard } + +/** + * 页面只订阅 Controller 的 WorkflowRun 并把它投影为画布;选择、菜单、位置和 busy + * 都是临时 UI 状态,不会写出第二份流程状态机。 + */ +export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {}) { const { runId } = useParams<{ runId: string }>() + const [session, setSession] = useState(null) + const [character, setCharacter] = useState(null) + const [run, setRun] = useState(null) + const [generations, setGenerations] = useState>({}) + const [selectedImages, setSelectedImages] = useState>({}) + const [actionMenuOpen, setActionMenuOpen] = useState(false) + const [actionMenuLevel, setActionMenuLevel] = useState('root') + const [selectedOutfitId, setSelectedOutfitId] = useState(null) + /** 正在执行命令的分支。必须是集合:并行分支各自持锁,后起的不能顶掉先起的。 */ + const [busyBranches, setBusyBranches] = useState>(() => new Set()) + const [error, setError] = useState(null) + const [resumeError, setResumeError] = useState(null) + const [generationReadError, setGenerationReadError] = useState(null) + const [canvasNodes, setCanvasNodes] = useState([]) + /** 当前会话的有效期。换 WorkflowRun 就 abort,所有异步回调只认这一个信号。 */ + const sessionAbortRef = useRef(null) + /** 最近一次生成结果读取。同一会话内被新的读取顶掉时 abort,避免旧结果盖住新结果。 */ + const latestReadAbortRef = useRef(null) + /** 已到终态的生成结果,按 taskId 记住,避免每次推进都重拉一遍。 */ + const settledGenerationsRef = useRef(new Map()) + + const requestGenerations = useCallback( + (targetSession: WorkflowEditorSession, targetRun: WorkflowRun, sessionSignal: AbortSignal) => { + latestReadAbortRef.current?.abort() + const readAbort = new AbortController() + latestReadAbortRef.current = readAbort + const outdated = () => sessionSignal.aborted || readAbort.signal.aborted + void readGenerations(targetSession.controller, targetRun, settledGenerationsRef.current) + .then((results) => { + if (outdated()) return + setGenerations(results) + setGenerationReadError(null) + }) + .catch((cause: unknown) => { + if (outdated()) return + setGenerationReadError(errorMessage(cause, '读取生成结果失败')) + }) + }, + [], + ) + + const runCommand = useCallback( + (branchKey: string, command: () => Promise) => { + // 同一分支内互斥,防重复提交;别的分支照常能操作,也不会被这条命令解锁。 + if (busyBranches.has(branchKey)) return + const sessionSignal = sessionAbortRef.current?.signal + setBusyBranches((current) => new Set(current).add(branchKey)) + setError(null) + void command() + .catch((cause: unknown) => { + if (sessionSignal?.aborted) return + setError(errorMessage(cause, '工作流命令执行失败')) + }) + .finally(() => { + if (sessionSignal?.aborted) return + setBusyBranches((current) => { + if (!current.has(branchKey)) return current + const next = new Set(current) + next.delete(branchKey) + return next + }) + }) + }, + [busyBranches], + ) + + useEffect(() => { + const sessionAbort = new AbortController() + sessionAbortRef.current = sessionAbort + latestReadAbortRef.current?.abort() + settledGenerationsRef.current = new Map() + setSession(null) + setCharacter(null) + setRun(null) + setGenerations({}) + setSelectedImages({}) + setActionMenuOpen(false) + setActionMenuLevel('root') + setSelectedOutfitId(null) + setBusyBranches(new Set()) + setError(null) + setResumeError(null) + setGenerationReadError(null) + setCanvasNodes([]) + if (!runId) return + + let loaded: WorkflowEditorSession | null = null + let unsubscribe: () => void = () => undefined + let unsubscribeErrors: () => void = () => undefined + const loader = loadSession ?? createDefaultRealWorkflowEditorSession + const signal = sessionAbort.signal + + void loader(runId) + .then(async (nextSession) => { + if (signal.aborted) { + nextSession.dispose() + return + } + loaded = nextSession + setSession(nextSession) + setCharacter(nextSession.character) + unsubscribeErrors = nextSession.subscribeErrors((nextError) => { + if (signal.aborted) return + setError(errorMessage(nextError, '工作流异步处理失败')) + }) + unsubscribe = nextSession.controller.subscribe((nextRun) => { + if (signal.aborted) return + setRun(nextRun) + requestGenerations(nextSession, nextRun, signal) + }) + try { + await nextSession.controller.resume() + } catch (cause: unknown) { + if (signal.aborted) return + setResumeError(errorMessage(cause, '恢复 WorkflowRun 失败')) + } + }) + .catch((cause: unknown) => { + if (signal.aborted) return + setError(errorMessage(cause, '恢复 WorkflowRun 失败')) + }) + + return () => { + sessionAbort.abort() + latestReadAbortRef.current?.abort() + unsubscribe() + unsubscribeErrors() + loaded?.dispose() + } + }, [loadSession, requestGenerations, runId]) + + const projected = useMemo( + () => + run && session + ? projectCanvas({ + run, + controller: session.controller, + publishReviewedAction: session.publishReviewedAction, + project: session.project, + character, + generations, + selectedImages, + actionMenuOpen, + actionMenuLevel, + selectedOutfitId, + busyBranches, + resumeBlocked: Boolean(resumeError), + setSelectedImages, + setActionMenuOpen, + setActionMenuLevel, + setSelectedOutfitId, + setCharacter, + runCommand, + }) + : { nodes: [] as WorkflowCardNode[], edges: [] as Edge[] }, + [ + actionMenuOpen, + actionMenuLevel, + busyBranches, + character, + generations, + run, + runCommand, + resumeError, + selectedImages, + selectedOutfitId, + session, + ], + ) + + useEffect(() => { + setCanvasNodes((previous) => + projected.nodes.map((node) => ({ + ...node, + position: previous.find((candidate) => candidate.id === node.id)?.position ?? node.position, + })), + ) + }, [projected.nodes]) + + useEffect(() => { + if ( + resumeError && + run && + !run.nodes.some( + (node) => !node.deletedAt && node.status === 'active' && node.phase === 'generating', + ) + ) { + setResumeError(null) + } + }, [resumeError, run]) + + function onNodesChange(changes: NodeChange[]) { + const safeChanges = changes.filter((change) => change.type !== 'remove') + setCanvasNodes((nodes) => applyNodeChanges(safeChanges, nodes)) + } + + if (!runId) { + return + } + + if (error && !run) { + return + } + + if (!session || !run) { + return + } + + const constraints = [ + CHARACTER_PERSPECTIVE[session.project.perspective], + DIRECTIONAL_MOVEMENT[session.project.directionalMovement], + `${session.project.spriteSize.width} × ${session.project.spriteSize.height}`, + session.project.gameStyle ?? '未设置画风', + ] + const visibleError = error ?? resumeError ?? generationReadError + return ( - -
-
-

WORKFLOW EDITOR

-

- {runId ? `工作流 ${runId.slice(0, 8)}` : '工作流画布'} -

-

- 首帧确认后先选择资产生产路线,再进入完整动画生成。 +

+
- -
    - {WORKFLOW_CARDS.map((card) => ( -
  1. - {card.index} -

    {card.title}

    -

    {card.detail}

    - {card.index === '04' ? ( - - ) : null} -
  2. - ))} -
+

{session.project.name}

+ +

+ {constraints.join(' · ')} +

+
+ + 真实 WorkflowRun 接口 + + + Run {run.id} · v{run.version} + +
+ + {visibleError ? ( +
+ {visibleError} + {!error && !resumeError && generationReadError ? ( + + ) : null} +
+ ) : null} +
+ + nodes={canvasNodes} + edges={projected.edges} + nodeTypes={nodeTypes} + onNodesChange={onNodesChange} + nodesDraggable + nodesConnectable={false} + edgesReconnectable={false} + elementsSelectable + deleteKeyCode={null} + fitView + fitViewOptions={{ padding: 0.14, maxZoom: 0.82 }} + minZoom={0.3} + maxZoom={1.2} + > + node.id)} /> + +
- + ) } -function GenerationMethodChoice({ - selected, - onSelect, +/** + * 只有画布上的节点增减时才重新取景,让新长出来的分支进入视野。 + * 节点内容变化(生成结果到达、状态推进、版本号增加)一律不动视角—— + * 用户拖过、缩放过的位置是他自己选的,命令执行不该把它拽回全景。 + * + * 延迟一帧再取景:新节点要等 React Flow 量完尺寸,立刻调会按旧尺寸算错取景框。 + */ +function FitViewOnNodeSetChange({ nodeIds }: { nodeIds: string[] }) { + const { fitView } = useReactFlow() + const signature = nodeIds.join(',') + useEffect(() => { + if (!signature) return + const timer = window.setTimeout(() => { + void fitView({ padding: 0.14, maxZoom: 0.82, duration: 180 }) + }, 32) + return () => window.clearTimeout(timer) + }, [fitView, signature]) + return null +} + +interface ProjectionInput { + run: WorkflowRun + controller: WorkflowController + publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise + project: Project + character: Character | null + generations: Record + selectedImages: Record + actionMenuOpen: boolean + actionMenuLevel: ActionMenuLevel + selectedOutfitId: string | null + busyBranches: ReadonlySet + resumeBlocked: boolean + setSelectedImages: React.Dispatch>> + setActionMenuOpen(open: boolean): void + setActionMenuLevel(level: ActionMenuLevel): void + setSelectedOutfitId(outfitId: string | null): void + setCharacter(character: Character): void + runCommand(branchKey: string, command: () => Promise): void +} + +/** 卡片自己所属的分支;命令与禁用判断都以它为准。 */ +function branchKeyOf(node: WorkflowNode, input: ProjectionInput): string { + return branchKeyFor(node, new Map(input.run.nodes.map((candidate) => [candidate.id, candidate]))) +} + +function projectCanvas(input: ProjectionInput): { + nodes: WorkflowCardNode[] + edges: Edge[] +} { + const activeNodes = input.run.nodes.filter((node) => !node.deletedAt) + const actionRootIds = activeNodes + .filter((node) => node.type === 'action-first-frame') + .map((node) => node.id) + const nodesById = new Map(activeNodes.map((node) => [node.id, node])) + const nodes = activeNodes.map((node) => + toCanvasNode(node, branchIndexFor(branchKeyFor(node, nodesById), actionRootIds), input), + ) + const edges: Edge[] = activeNodes.flatMap((node) => { + const confirmed = node.status === 'passed' + return node.dependsOnNodeIds.map((source) => ({ + id: `${source}->${node.id}`, + source, + target: node.id, + selectable: false, + deletable: false, + className: confirmed ? 'workflow-edge--confirmed' : 'workflow-edge--flowing', + })) + }) + + return { nodes, edges } +} + +function toCanvasNode( + node: WorkflowNode, + branchIndex: number, + input: ProjectionInput, +): WorkflowCardNode { + return { + id: node.id, + type: 'workflow-card', + position: positionFor(node.type, branchIndex), + zIndex: node.type === 'character-template' && input.actionMenuOpen ? 1000 : 0, + draggable: true, + dragHandle: '.workflow-card__handle', + deletable: false, + data: { + eyebrow: CARD_LABELS[node.type].eyebrow, + title: CARD_LABELS[node.type].title, + status: node.status, + content: contentFor(node, input), + }, + } +} + +function contentFor(node: WorkflowNode, input: ProjectionInput): ReactNode { + if (node.type === 'character-setup') return + if (node.type === 'character-template') { + return + } + if (node.type === 'action-first-frame') return + if (node.type === 'action-generation-method') return + if (node.type === 'action-full-frame') return + return +} + +function CharacterSetupContent({ + node, + input, }: { - selected: ActionGenerationMethod | null - onSelect?: (method: ActionGenerationMethod) => void + node: CharacterSetupWorkflowNode + input: ProjectionInput }) { + const branchKey = branchKeyOf(node, input) + const branchBusy = input.busyBranches.has(branchKey) + const [prompt, setPrompt] = useState(node.input.prompt) + useEffect(() => setPrompt(node.input.prompt), [node.id, node.input.prompt]) + + if (node.status === 'failed') return + if (node.status === 'passed') return

角色描述已确认

return ( -
+
+