From 4dd941330dd11b1b6fb76b56fa17fb956f51f8fe Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:36:44 +0800 Subject: [PATCH 1/9] feat(workflow-controller): coordinate one workflow run --- frontend/src/entities/generation/index.ts | 3 + .../features/workflow-controller/README.md | 29 + .../workflow-controller/controller.test.ts | 638 ++++++++++++++ .../workflow-controller/controller.ts | 796 ++++++++++++++++++ .../src/features/workflow-controller/index.ts | 65 +- 5 files changed, 1475 insertions(+), 56 deletions(-) create mode 100644 frontend/src/features/workflow-controller/README.md create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.ts diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 33f21313..d6841b6f 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -35,6 +35,9 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 必须与 Project 的精灵尺寸一致,后端会在提交时校验。 */ + spriteWidth: number + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md new file mode 100644 index 00000000..5d779610 --- /dev/null +++ b/frontend/src/features/workflow-controller/README.md @@ -0,0 +1,29 @@ +# WorkflowController + +`WorkflowController` 是页面与实体接口之间的业务协调器。一个实例只绑定一条 +`WorkflowRun`;它同时持有当前数据和修改这份数据的业务方法。 + +## 两种入口 + +- Workflow Editor 等待用户逐步调用生成、确认和审核方法。 +- Quick Start 用 AI 自动做选择并连续调用同一组方法。 + +两者界面和交互不同,但不会各自维护另一套工作流状态机。Controller 本身也不保存 +`driver`,因为“由谁点击”不改变节点图的业务规则。 + +## 边界 + +- `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 +- Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。 +- Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 +- WorkflowRun 只有在后端 `update` 成功后才替换内存快照,保存失败不会向页面假报成功。 +- Generation 已创建但任务引用暂时保存失败时,本实例会保留待附加记录;重试同一命令或 + `resume()` 会复用原任务,不会再次创建和重复计费。 +- 中断只停止前端自动处理和 SSE。当前后端没有取消接口,因此不会伪装成已取消任务; + 恢复时先订阅再查询任务快照,既能拿终态,也不会漏掉查询与订阅之间的完成事件。 +- Controller 不包含页面、Playtest、后端实现、发布和导出逻辑。 + +## 文件 + +- `controller.ts`:单 WorkflowRun 的业务方法、持久化串行化和 Generation 恢复。 +- `controller.test.ts`:节点依赖、并行、中断、重做、异步竞争和持久化失败测试。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 00000000..97fd2809 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,638 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { + CharacterWorkflowNode, + Generation, + GenerationApis, + GenerationEvent, + WorkflowActionInput, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { createWorkflowController } from '.' + +function characterNode(overrides: Partial = {}): CharacterWorkflowNode { + return { + id: 'character-1', + type: 'character', + status: 'active', + phase: 'configuring_character', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '像素骑士', referenceMedia: [] }, + selectedImageUrl: null, + ...overrides, + } +} + +function actionInput(overrides: Partial = {}): WorkflowActionInput { + return { + outfitId: 'outfit-1', + name: '行走', + type: 'walk', + prompt: null, + fps: 12, + ...overrides, + } +} + +function createRun(nodes: WorkflowNode[] = [characterNode()]): WorkflowRun { + return { + id: 'run-1', + projectId: '1', + version: 1, + storageStatus: 'active', + nodes, + } +} + +function createWorkflowApis(initial: WorkflowRun = createRun()) { + let saved = structuredClone(initial) + const apis: WorkflowRunApis = { + create: vi.fn(async (input) => { + saved = { + id: 'run-1', + projectId: input.projectId, + version: 1, + storageStatus: 'active', + nodes: structuredClone(input.nodes), + } + return structuredClone(saved) + }), + get: vi.fn(async () => structuredClone(saved)), + update: vi.fn(async (run) => { + saved = { ...structuredClone(run), version: saved.version + 1 } + return structuredClone(saved) + }), + remove: vi.fn(async () => undefined), + } + return { apis, getSaved: () => structuredClone(saved) } +} + +function createGenerationHarness() { + const listeners = new Map void>() + const snapshots = new Map() + let nextId = 1 + const apis: GenerationApis = { + create: vi.fn(async (input) => { + const generation: Generation = { + id: `task-${nextId++}`, + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + } + snapshots.set(generation.id, generation) + return generation + }) as GenerationApis['create'], + get: vi.fn(async (_projectId, id) => { + const generation = snapshots.get(id) + if (!generation) throw new Error(`Generation 不存在:${id}`) + return structuredClone(generation) + }), + subscribe: vi.fn((_projectId, id, onEvent) => { + listeners.set(id, onEvent) + return () => listeners.delete(id) + }), + } + + function emit(event: GenerationEvent) { + snapshots.set(event.taskId, { + id: event.taskId, + projectId: '1', + type: event.type, + status: event.status, + result: event.result, + error: event.error, + }) + listeners.get(event.taskId)?.(event) + } + + return { apis, emit, listeners, snapshots } +} + +function createController(run = createRun()) { + const workflow = createWorkflowApis(run) + const generation = createGenerationHarness() + const asyncErrors: Error[] = [] + const controller = createWorkflowController({ + workflow: run, + workflowRunApis: workflow.apis, + generationApis: generation.apis, + createId: () => 'action-created', + onAsyncError: (error) => asyncErrors.push(error), + }) + return { controller, workflow, generation, asyncErrors } +} + +async function flushAsyncWork() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('WorkflowController', () => { + it('一个实例只绑定一条 WorkflowRun,创建后不能换成另一条', async () => { + const workflow = createWorkflowApis() + const generation = createGenerationHarness() + const controller = createWorkflowController({ + workflowRunApis: workflow.apis, + generationApis: generation.apis, + onAsyncError: vi.fn(), + }) + + const created = await controller.create({ projectId: '1', nodes: [characterNode()] }) + + expect(controller.getWorkflow()).toEqual(created) + await expect( + controller.create({ projectId: '2', nodes: [characterNode({ id: 'other' })] }), + ).rejects.toThrow('已经绑定') + }) + + it('角色通过后按显式依赖边同时解锁多个 Action', async () => { + const run = createRun([ + characterNode({ phase: 'selecting_character' }), + { + id: 'action-walk', + type: 'action', + status: 'locked', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + }, + { + id: 'action-jump', + type: 'action', + status: 'locked', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput({ name: '跳跃', type: 'jump' }), + selectedFirstFrameUrl: null, + }, + ]) + const { controller } = createController(run) + + const next = await controller.confirmCharacter('character-1', 'https://img/knight.png') + + expect(next.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'character-1', status: 'passed', phase: 'completed' }), + expect.objectContaining({ id: 'action-walk', status: 'active' }), + expect.objectContaining({ id: 'action-jump', status: 'active' }), + ]), + ) + }) + + it('角色生成任务落库并从终态事件进入候选确认阶段', async () => { + const { controller, workflow, generation, asyncErrors } = createController() + + await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + expect(generation.apis.create).toHaveBeenCalledWith( + expect.objectContaining({ spriteWidth: 64, spriteHeight: 64 }), + ) + const inFlight = workflow.getSaved().nodes[0] + expect(inFlight).toMatchObject({ + phase: 'generating_character_candidates', + generations: [{ taskId: 'task-1', role: 'character_candidates' }], + }) + + generation.emit({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/knight.png' }], + }, + error: null, + }) + await flushAsyncWork() + + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + status: 'active', + phase: 'selecting_character', + error: null, + }) + expect(asyncErrors).toEqual([]) + }) + + it('SSE 与紧随其后的查询同时返回终态时只保存一次结果', async () => { + const workflow = createWorkflowApis() + const terminalEvent: GenerationEvent = { + taskId: 'task-terminal', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/knight.png' }], + }, + error: null, + } + const generationApis: GenerationApis = { + create: vi.fn(async () => ({ + id: 'task-terminal', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + })) as GenerationApis['create'], + get: vi.fn(async () => ({ + id: terminalEvent.taskId, + projectId: '1', + type: terminalEvent.type, + status: terminalEvent.status, + result: terminalEvent.result, + error: terminalEvent.error, + })), + subscribe: vi.fn((_projectId, _taskId, onEvent) => { + onEvent(terminalEvent) + return () => undefined + }), + } + const controller = createWorkflowController({ + workflow: createRun(), + workflowRunApis: workflow.apis, + generationApis, + onAsyncError: vi.fn(), + }) + + await controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(workflow.apis.update).toHaveBeenCalledTimes(2) + expect(controller.getWorkflow().nodes[0].phase).toBe('selecting_character') + }) + + it('中断后忽略迟到结果,恢复时查询终态再推进', async () => { + const { controller, generation } = createController() + await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.interrupt() + + generation.emit({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/knight.png' }], + }, + error: null, + }) + await flushAsyncWork() + expect(controller.getWorkflow().nodes[0].phase).toBe('generating_character_candidates') + + await controller.resume() + expect(controller.getWorkflow().nodes[0].phase).toBe('selecting_character') + }) + + it('从节点重做会清掉下游和旧 task,旧事件不能覆盖新执行线', async () => { + const run = createRun([ + characterNode({ + phase: 'generating_character_candidates', + generations: [{ taskId: 'task-old', role: 'character_candidates' }], + }), + { + id: 'action-walk', + type: 'action', + status: 'locked', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + }, + ]) + const { controller } = createController(run) + + await controller.restartFromNode('character-1') + await controller.applyGenerationResult({ + nodeId: 'character-1', + taskId: 'task-old', + generation: { + id: 'task-old', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/stale.png' }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes).toEqual([ + expect.objectContaining({ + id: 'character-1', + status: 'active', + phase: 'configuring_character', + generations: [], + }), + expect.objectContaining({ id: 'action-walk', status: 'locked', generations: [] }), + ]) + }) + + it('生成请求尚未返回时重做,旧任务不能挂回新执行线', async () => { + const workflow = createWorkflowApis() + const pendingResolvers: Array<(generation: Generation) => void> = [] + const snapshots = new Map() + const createGeneration = vi.fn( + () => + new Promise((resolve) => { + pendingResolvers.push((generation) => { + snapshots.set(generation.id, generation) + resolve(generation) + }) + }), + ) as unknown as GenerationApis['create'] + const generationApis: GenerationApis = { + create: createGeneration, + get: vi.fn(async (_projectId, id) => structuredClone(snapshots.get(id)!)), + subscribe: vi.fn(() => () => undefined), + } + const controller = createWorkflowController({ + workflow: createRun(), + workflowRunApis: workflow.apis, + generationApis, + onAsyncError: vi.fn(), + }) + + const oldSubmission = controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + await Promise.resolve() + await controller.restartFromNode('character-1') + + const newSubmission = controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + await Promise.resolve() + expect(createGeneration).toHaveBeenCalledTimes(2) + + pendingResolvers[0]?.({ + id: 'task-old', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + await oldSubmission + const sameNewSubmission = controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + expect(createGeneration).toHaveBeenCalledTimes(2) + + pendingResolvers[1]?.({ + id: 'task-new', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + await Promise.all([newSubmission, sameNewSubmission]) + + expect(controller.getWorkflow().nodes[0].generations).toEqual([ + { taskId: 'task-new', role: 'character_candidates' }, + ]) + }) + + it('保存失败时不发布未落库的新状态', async () => { + const { controller, workflow } = createController( + createRun([characterNode({ phase: 'selecting_character' })]), + ) + vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) + + await expect( + controller.confirmCharacter('character-1', 'https://img/knight.png'), + ).rejects.toThrow('后端保存失败') + + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + status: 'active', + phase: 'selecting_character', + selectedImageUrl: null, + }) + }) + + it('生成任务创建成功但引用保存失败时,重试复用同一个任务', async () => { + const { controller, workflow, generation } = createController() + vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) + + await expect( + controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('后端保存失败') + expect(controller.getWorkflow().nodes[0].generations).toEqual([]) + + await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + phase: 'generating_character_candidates', + generations: [{ taskId: 'task-1', role: 'character_candidates' }], + }) + }) + + it('同一节点并发点击只创建一个生成任务', async () => { + const { controller, generation } = createController() + + await Promise.all([ + controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + ]) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + }) + + it('完整动画必须是 32 帧,通过审核后节点才完成', async () => { + const frames = Array.from({ length: 32 }, (_, index) => ({ + url: `https://img/frame-${index}.png`, + })) + const run = createRun([ + characterNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + { + id: 'action-walk', + type: 'action', + status: 'active', + phase: 'generating_animation', + dependsOnNodeIds: ['character-1'], + generations: [{ taskId: 'task-animation', role: 'animation' }], + error: null, + input: actionInput(), + selectedFirstFrameUrl: 'https://img/first.png', + }, + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk', + taskId: 'task-animation', + generation: { + id: 'task-animation', + projectId: '1', + type: 'complete_animation', + status: 'completed', + result: { type: 'complete_animation', frames }, + error: null, + }, + }) + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'reviewing_animation', + }) + + await controller.approveAction('action-walk') + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'passed', + phase: 'completed', + }) + }) + + it('同一 Action 节点依次生成首帧和 32 帧动画', async () => { + const run = createRun([ + characterNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + { + id: 'action-walk', + type: 'action', + status: 'active', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + }, + ]) + const { controller, generation } = createController(run) + + await controller.generateActionFrame('action-walk', { + characterId: 'character-backend-1', + referenceMedia: [], + }) + generation.emit({ + taskId: 'task-1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: 'https://img/first.png' } }, + error: null, + }) + await flushAsyncWork() + await controller.confirmActionFrame('action-walk', 'https://img/first.png') + + await controller.generateAnimation('action-walk', { + characterId: 'character-backend-1', + referenceMedia: [], + }) + generation.emit({ + taskId: 'task-2', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: Array.from({ length: 32 }, (_, index) => ({ + url: `https://img/frame-${index}.png`, + })), + }, + error: null, + }) + await flushAsyncWork() + await controller.approveAction('action-walk') + + expect(generation.apis.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: 'first_frame', + characterId: 'character-backend-1', + outfitId: 'outfit-1', + }), + ) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'complete_animation', + firstFrameUrl: 'https://img/first.png', + }), + ) + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'passed', + phase: 'completed', + generations: [ + { taskId: 'task-1', role: 'action_frame_candidates' }, + { taskId: 'task-2', role: 'animation' }, + ], + }) + }) + + it('恢复动画阶段时不会让旧首帧任务把节点倒退', async () => { + const run = createRun([ + characterNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + { + id: 'action-walk', + type: 'action', + status: 'active', + phase: 'generating_animation', + dependsOnNodeIds: ['character-1'], + generations: [ + { taskId: 'task-first-frame', role: 'action_frame_candidates' }, + { taskId: 'task-animation', role: 'animation' }, + ], + error: null, + input: actionInput(), + selectedFirstFrameUrl: 'https://img/first.png', + }, + ]) + const { controller, generation } = createController(run) + generation.snapshots.set('task-first-frame', { + id: 'task-first-frame', + projectId: '1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: 'https://img/first.png' } }, + error: null, + }) + generation.snapshots.set('task-animation', { + id: 'task-animation', + projectId: '1', + type: 'complete_animation', + status: 'running', + result: null, + error: null, + }) + + await controller.resume() + await controller.applyGenerationResult({ + nodeId: 'action-walk', + taskId: 'task-first-frame', + generation: generation.snapshots.get('task-first-frame')!, + }) + + expect(generation.apis.get).toHaveBeenCalledTimes(1) + expect(generation.apis.get).toHaveBeenCalledWith('1', 'task-animation') + expect(controller.getWorkflow().nodes[1].phase).toBe('generating_animation') + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 00000000..2a17e403 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,796 @@ +import type { + ActionWorkflowNode, + CharacterTemplateGenerationInput, + CharacterWorkflowNode, + CompleteAnimationGenerationInput, + CreateWorkflowRunInput, + FirstFrameGenerationInput, + Generation, + GenerationApis, + GenerationEvent, + MediaReference, + WorkflowActionInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' + +const COMPLETE_ANIMATION_FRAME_COUNT = 32 + +export interface AddActionInput { + /** 未传时由 Controller 生成,仅用于前端节点图。 */ + nodeId?: WorkflowNode['id'] + /** 默认依赖当前图中的 Character 节点。 */ + dependsOnNodeIds?: readonly WorkflowNode['id'][] + input: WorkflowActionInput +} + +export interface GenerateCharacterOptions { + spriteWidth: number + spriteHeight: number +} + +export interface GenerateActionOptions { + characterId: string + /** 由上传/媒体边界提供,Controller 不把展示 URL 冒充 MediaReference。 */ + referenceMedia: readonly MediaReference[] +} + +export interface ApplyGenerationResultInput { + nodeId: WorkflowNode['id'] + taskId: Generation['id'] + generation: Generation +} + +export interface CreateWorkflowControllerOptions { + /** 已从 WorkflowRunApis.get 取回的运行记录;不传时只能先调用 create。 */ + workflow?: WorkflowRun + workflowRunApis: WorkflowRunApis + generationApis: GenerationApis + createId?: () => string + /** SSE 回调无法 await,异步保存错误通过此处交给装配层展示或记录。 */ + onAsyncError: (error: Error) => void +} + +/** + * 一个 Controller 只维护一条 WorkflowRun。 + * + * Quick Start 与 Workflow Editor 调用同一组业务方法,区别只在于前者自动选择并连续 + * 调用、后者等待用户逐步点击。Controller 不识别入口,也不保存第二份流程模型。 + */ +export interface WorkflowController { + create(input: CreateWorkflowRunInput): Promise + getWorkflow(): WorkflowRun + + addAction(input: AddActionInput): Promise + generateCharacter( + nodeId: CharacterWorkflowNode['id'], + options: GenerateCharacterOptions, + ): Promise + confirmCharacter( + nodeId: CharacterWorkflowNode['id'], + selectedImageUrl: string, + ): Promise + generateActionFrame( + nodeId: ActionWorkflowNode['id'], + options: GenerateActionOptions, + ): Promise + confirmActionFrame( + nodeId: ActionWorkflowNode['id'], + selectedFirstFrameUrl: string, + ): Promise + generateAnimation( + nodeId: ActionWorkflowNode['id'], + options: GenerateActionOptions, + ): Promise + approveAction(nodeId: ActionWorkflowNode['id']): Promise + + /** 刷新恢复时查询已记录的 Generation,再恢复 SSE。 */ + resume(): Promise + /** 停止本实例的自动处理;后端没有 cancel,所以不会伪装成取消了服务端任务。 */ + interrupt(): Promise + restartFromNode(nodeId: WorkflowNode['id']): Promise + applyGenerationResult(input: ApplyGenerationResultInput): Promise + getGeneration( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + ): Promise + dispose(): void +} + +interface ActiveSubscription { + nodeId: WorkflowNode['id'] + taskId: Generation['id'] + stop: () => void +} + +interface PendingGenerationAttachment { + nodeId: WorkflowNode['id'] + role: WorkflowGenerationRole + expectedEpoch: number + generation: Generation +} + +export function createWorkflowController({ + workflow, + workflowRunApis, + generationApis, + createId = createBrowserSafeId, + onAsyncError, +}: CreateWorkflowControllerOptions): WorkflowController { + let current = workflow ? structuredClone(workflow) : null + let interrupted = false + let saveQueue: Promise = Promise.resolve() + const submissions = new Map>() + const subscriptions = new Map() + const nodeEpochs = new Map() + const unattachedGenerations = new Map() + const settlements = new Map>() + + function requireWorkflow(): WorkflowRun { + if (!current) throw new Error('WorkflowController 尚未绑定 WorkflowRun') + return current + } + + function snapshot(): WorkflowRun { + return structuredClone(requireWorkflow()) + } + + function ensureRunning() { + if (interrupted) throw new Error('WorkflowController 已中断,请先调用 resume') + } + + function enqueue(operation: () => Promise): Promise { + const result = saveQueue.then(operation) + saveQueue = result.then( + () => undefined, + () => undefined, + ) + return result + } + + function persist(transform: (run: WorkflowRun) => WorkflowRun): Promise { + return enqueue(async () => { + const before = requireWorkflow() + const candidate = transform(before) + if (candidate === before) return structuredClone(before) + + // 只有后端确认保存后才替换内存快照;失败时页面不会看到“假成功”。 + const saved = await workflowRunApis.update(candidate) + current = structuredClone(saved) + return structuredClone(saved) + }) + } + + function create(input: CreateWorkflowRunInput): Promise { + return enqueue(async () => { + if (current) throw new Error('WorkflowController 已经绑定一条 WorkflowRun') + const created = await workflowRunApis.create({ + ...input, + nodes: normalizeAvailability(input.nodes), + }) + current = structuredClone(created) + return structuredClone(created) + }) + } + + function getWorkflow() { + return snapshot() + } + + function addAction({ nodeId = createId(), dependsOnNodeIds, input }: AddActionInput) { + ensureRunning() + return persist((run) => { + if (run.nodes.some((node) => node.id === nodeId)) { + throw new Error(`WorkflowNode 已存在:${nodeId}`) + } + const dependencies = dependsOnNodeIds + ? [...dependsOnNodeIds] + : run.nodes.filter((node) => node.type === 'character').map((node) => node.id) + assertDependenciesExist(run.nodes, dependencies) + const node: ActionWorkflowNode = { + id: nodeId, + type: 'action', + status: dependencies.every((id) => isPassed(run.nodes, id)) ? 'active' : 'locked', + phase: 'configuring_action', + dependsOnNodeIds: dependencies, + generations: [], + error: null, + input: structuredClone(input), + selectedFirstFrameUrl: null, + } + return { ...run, nodes: [...run.nodes, node] } + }) + } + + function generateCharacter( + nodeId: CharacterWorkflowNode['id'], + options: GenerateCharacterOptions, + ) { + ensurePositiveInteger(options.spriteWidth, 'spriteWidth') + ensurePositiveInteger(options.spriteHeight, 'spriteHeight') + return submitGeneration(nodeId, 'character_candidates', (run, node) => { + if (node.type !== 'character') throw new Error('目标节点不是 Character') + if (node.phase !== 'configuring_character') throw new Error('角色节点当前不能开始生成') + const input: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: node.input.prompt, + referenceMedia: node.input.referenceMedia, + ...options, + } + return input + }) + } + + function confirmCharacter(nodeId: CharacterWorkflowNode['id'], selectedImageUrl: string) { + ensureRunning() + const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'character') throw new Error('目标节点不是 Character') + if (node.status !== 'active' || node.phase !== 'selecting_character') { + throw new Error('角色节点当前不能确认候选图') + } + return unlockReadyNodes({ + ...run, + nodes: run.nodes.map((item) => + item.id === node.id + ? { ...node, selectedImageUrl: imageUrl, phase: 'completed', status: 'passed' } + : item, + ), + }) + }), + ) + } + + function generateActionFrame(nodeId: ActionWorkflowNode['id'], options: GenerateActionOptions) { + const characterId = nonEmpty(options.characterId, 'characterId') + return submitGeneration(nodeId, 'action_frame_candidates', (run, node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.phase !== 'configuring_action') throw new Error('Action 节点当前不能生成首帧') + const input: FirstFrameGenerationInput = { + type: 'first_frame', + projectId: run.projectId, + characterId, + outfitId: node.input.outfitId, + actionType: node.input.type, + prompt: node.input.prompt, + referenceMedia: options.referenceMedia, + } + return input + }) + } + + function confirmActionFrame(nodeId: ActionWorkflowNode['id'], selectedFirstFrameUrl: string) { + ensureRunning() + const imageUrl = nonEmpty(selectedFirstFrameUrl, 'selectedFirstFrameUrl') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.status !== 'active' || node.phase !== 'selecting_action_frame') { + throw new Error('Action 节点当前不能确认首帧') + } + return replaceNode(run, { ...node, selectedFirstFrameUrl: imageUrl }) + }), + ) + } + + function generateAnimation(nodeId: ActionWorkflowNode['id'], options: GenerateActionOptions) { + const characterId = nonEmpty(options.characterId, 'characterId') + return submitGeneration(nodeId, 'animation', (run, node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.phase !== 'selecting_action_frame' || !node.selectedFirstFrameUrl) { + throw new Error('Action 节点尚未确认首帧') + } + const input: CompleteAnimationGenerationInput = { + type: 'complete_animation', + projectId: run.projectId, + characterId, + outfitId: node.input.outfitId, + actionType: node.input.type, + firstFrameUrl: node.selectedFirstFrameUrl, + prompt: node.input.prompt, + referenceMedia: options.referenceMedia, + } + return input + }) + } + + function approveAction(nodeId: ActionWorkflowNode['id']) { + ensureRunning() + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.status !== 'active' || node.phase !== 'reviewing_animation') { + throw new Error('Action 节点当前不能通过审核') + } + return unlockReadyNodes( + replaceNode(run, { ...node, status: 'passed', phase: 'completed', error: null }), + ) + }), + ) + } + + function submitGeneration( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + ): Promise { + ensureRunning() + const key = `${nodeId}:${role}` + const active = submissions.get(key) + if (active) return active + + const expectedEpoch = nodeEpoch(nodeId) + const submission = performGenerationSubmission( + nodeId, + role, + expectedEpoch, + createInput, + ).finally(() => { + if (submissions.get(key) === submission) submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performGenerationSubmission( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + expectedEpoch: number, + createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + ): Promise { + const before = requireWorkflow() + const node = findNode(before, nodeId) + assertNodeCanRun(before, node) + const key = `${nodeId}:${role}` + const existing = node.generations.find((item) => item.role === role) + if (existing) { + await watchGeneration(node.id, existing.taskId) + return snapshot() + } + + const pendingAttachment = unattachedGenerations.get(key) + if (pendingAttachment?.expectedEpoch === expectedEpoch) { + return attachGeneration(pendingAttachment) + } + if (pendingAttachment) unattachedGenerations.delete(key) + + const generation = await generationApis.create(createInput(before, node)) + if (generation.projectId !== before.projectId) { + throw new Error('Generation 与 WorkflowRun 不属于同一项目') + } + // 重做发生在请求等待期间时,任务可以留在后端,但绝不能再挂回新的节点执行线。 + if (nodeEpoch(nodeId) !== expectedEpoch) return snapshot() + + const attachment = { nodeId, role, expectedEpoch, generation } + unattachedGenerations.set(key, attachment) + return attachGeneration(attachment) + } + + async function attachGeneration({ + nodeId, + role, + expectedEpoch, + generation, + }: PendingGenerationAttachment): Promise { + const key = `${nodeId}:${role}` + if (nodeEpoch(nodeId) !== expectedEpoch) { + if (unattachedGenerations.get(key)?.generation.id === generation.id) { + unattachedGenerations.delete(key) + } + return snapshot() + } + const attached = await persist((latest) => { + if (nodeEpoch(nodeId) !== expectedEpoch) return latest + const latestNode = findNode(latest, nodeId) + if (latestNode.generations.some((item) => item.role === role)) return latest + assertNodeCanRun(latest, latestNode) + return replaceNode(latest, { + ...latestNode, + phase: phaseForRunningRole(role), + generations: [...latestNode.generations, { taskId: generation.id, role }], + error: null, + }) + }) + const attachedReference = findNode(attached, nodeId).generations.find( + (item) => item.role === role, + ) + if (unattachedGenerations.get(key)?.generation.id === generation.id) { + unattachedGenerations.delete(key) + } + if (attachedReference?.taskId !== generation.id) { + return attached + } + + if (generation.status === 'completed' || generation.status === 'failed') { + return applyGenerationResult({ nodeId, taskId: generation.id, generation }) + } + await watchGeneration(nodeId, generation.id) + return snapshot() + } + + async function watchGeneration(nodeId: WorkflowNode['id'], taskId: Generation['id']) { + if (interrupted) return + const key = subscriptionKey(nodeId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { nodeId, taskId, stop: () => undefined }) + try { + const stop = generationApis.subscribe(requireWorkflow().projectId, taskId, (event) => { + if (event.taskId !== taskId || event.status === 'pending' || event.status === 'running') { + return + } + void settleGeneration(nodeId, taskId, event).catch((cause: unknown) => { + onAsyncError(asError(cause)) + }) + }) + const registered = subscriptions.get(key) + if (registered) subscriptions.set(key, { ...registered, stop }) + else stop() + + // 先订阅再查询,关闭“GET 看到运行中,订阅前任务已结束”的丢事件窗口。 + const latest = await generationApis.get(requireWorkflow().projectId, taskId) + if (latest.status === 'completed' || latest.status === 'failed') { + await settleGeneration(nodeId, taskId, latest) + } + } catch (cause) { + stopSubscription(key) + throw cause + } + } + + function settleGeneration( + nodeId: WorkflowNode['id'], + taskId: Generation['id'], + generation: Generation | GenerationEvent, + ): Promise { + if (interrupted) return Promise.resolve(snapshot()) + const key = subscriptionKey(nodeId, taskId) + const active = settlements.get(key) + if (active) return active + + const settlement = performSettlement(nodeId, taskId, generation).finally(() => { + if (settlements.get(key) === settlement) settlements.delete(key) + stopSubscription(key) + }) + settlements.set(key, settlement) + return settlement + } + + async function performSettlement( + nodeId: WorkflowNode['id'], + taskId: Generation['id'], + generation: Generation | GenerationEvent, + ) { + const normalized: Generation = + 'id' in generation + ? generation + : { + id: generation.taskId, + projectId: requireWorkflow().projectId, + type: generation.type, + status: generation.status, + result: generation.result, + error: generation.error, + } + return applyGenerationResult({ nodeId, taskId, generation: normalized }) + } + + function applyGenerationResult({ + nodeId, + taskId, + generation, + }: ApplyGenerationResultInput): Promise { + if (interrupted) return Promise.resolve(snapshot()) + return persist((run) => { + if (generation.id !== taskId || generation.projectId !== run.projectId) return run + const node = findNode(run, nodeId) + const reference = node.generations.find((item) => item.taskId === taskId) + if (!reference || node.status !== 'active') return run + // 一个 Action 会先后保留首帧和动画任务引用;只允许当前 phase 对应的任务推进。 + // 这样刷新恢复不会让已经完成的首帧任务把动画阶段倒退回首帧选择。 + if (node.phase !== phaseForRunningRole(reference.role)) return run + if (generation.status === 'pending' || generation.status === 'running') return run + if (generation.status === 'failed') { + return replaceNode(run, { + ...node, + status: 'failed', + error: generation.error?.trim() || '生成任务失败', + }) + } + return applyCompletedGeneration(run, node, reference, generation) + }) + } + + function applyCompletedGeneration( + run: WorkflowRun, + node: WorkflowNode, + reference: WorkflowGenerationRef, + generation: Generation, + ): WorkflowRun { + if (reference.role === 'character_candidates') { + if ( + node.type !== 'character' || + generation.type !== 'character_template' || + generation.result?.type !== 'character_template' || + generation.result.images.length === 0 + ) { + return failNode(run, node, '角色候选图结果格式无效') + } + return replaceNode(run, { ...node, phase: 'selecting_character', error: null }) + } + + if (reference.role === 'action_frame_candidates') { + if ( + node.type !== 'action' || + generation.type !== 'first_frame' || + generation.result?.type !== 'first_frame' || + !generation.result.image.url + ) { + return failNode(run, node, '动作首帧结果格式无效') + } + return replaceNode(run, { ...node, phase: 'selecting_action_frame', error: null }) + } + + if ( + node.type !== 'action' || + generation.type !== 'complete_animation' || + generation.result?.type !== 'complete_animation' + ) { + return failNode(run, node, '完整动画结果格式无效') + } + if (generation.result.frames.length !== COMPLETE_ANIMATION_FRAME_COUNT) { + return failNode( + run, + node, + `完整动画应为 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际为 ${generation.result.frames.length} 帧`, + ) + } + return replaceNode(run, { ...node, phase: 'reviewing_animation', error: null }) + } + + async function resume(): Promise { + interrupted = false + for (const attachment of [...unattachedGenerations.values()]) { + await attachGeneration(attachment) + } + const run = requireWorkflow() + const tasks = run.nodes.flatMap((node) => { + if (node.status !== 'active' || !isGeneratingPhase(node)) return [] + const role = roleForRunningPhase(node.phase) + const reference = node.generations.find((item) => item.role === role) + return reference ? [{ nodeId: node.id, taskId: reference.taskId }] : [] + }) + await Promise.all(tasks.map((task) => watchGeneration(task.nodeId, task.taskId))) + return snapshot() + } + + async function interrupt(): Promise { + interrupted = true + stopAllSubscriptions() + return snapshot() + } + + async function restartFromNode(nodeId: WorkflowNode['id']): Promise { + const before = requireWorkflow() + findNode(before, nodeId) + const affectedIds = collectDescendantIds(before.nodes, nodeId) + + const restarted = await persist((run) => { + const resetNodes = run.nodes.map((node) => + affectedIds.has(node.id) ? resetNode(node) : node, + ) + return { ...run, nodes: normalizeAvailability(resetNodes) } + }) + for (const affectedId of affectedIds) { + nodeEpochs.set(affectedId, nodeEpoch(affectedId) + 1) + for (const [key] of submissions) { + if (key.startsWith(`${affectedId}:`)) submissions.delete(key) + } + for (const [key] of unattachedGenerations) { + if (key.startsWith(`${affectedId}:`)) unattachedGenerations.delete(key) + } + } + // 不依赖重做前快照里的 taskId:引用保存与重做交错时,订阅可能刚刚才建立。 + for (const [key, subscription] of subscriptions) { + if (affectedIds.has(subscription.nodeId)) stopSubscription(key) + } + interrupted = false + return restarted + } + + async function getGeneration(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) { + const run = requireWorkflow() + const reference = findNode(run, nodeId).generations.find((item) => item.role === role) + return reference ? generationApis.get(run.projectId, reference.taskId) : null + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 释放传输连接失败不能反向改变已经持久化的 WorkflowRun。 + } + } + + function stopAllSubscriptions() { + for (const key of [...subscriptions.keys()]) stopSubscription(key) + } + + function dispose() { + interrupted = true + stopAllSubscriptions() + } + + function nodeEpoch(nodeId: WorkflowNode['id']) { + return nodeEpochs.get(nodeId) ?? 0 + } + + return { + create, + getWorkflow, + addAction, + generateCharacter, + confirmCharacter, + generateActionFrame, + confirmActionFrame, + generateAnimation, + approveAction, + resume, + interrupt, + restartFromNode, + applyGenerationResult, + getGeneration, + dispose, + } +} + +function updateNode( + run: WorkflowRun, + nodeId: WorkflowNode['id'], + update: (node: WorkflowNode) => WorkflowRun, +) { + return update(findNode(run, nodeId)) +} + +function findNode(run: WorkflowRun, nodeId: WorkflowNode['id']): WorkflowNode { + const node = run.nodes.find((item) => item.id === nodeId) + if (!node) throw new Error(`WorkflowNode 不存在:${nodeId}`) + return node +} + +function replaceNode(run: WorkflowRun, replacement: WorkflowNode): WorkflowRun { + return { + ...run, + nodes: run.nodes.map((node) => (node.id === replacement.id ? replacement : node)), + } +} + +function failNode(run: WorkflowRun, node: WorkflowNode, error: string): WorkflowRun { + return replaceNode(run, { ...node, status: 'failed', error }) +} + +function unlockReadyNodes(run: WorkflowRun): WorkflowRun { + return { + ...run, + nodes: run.nodes.map((node) => + node.status === 'locked' && + node.dependsOnNodeIds.every((dependencyId) => isPassed(run.nodes, dependencyId)) + ? { ...node, status: 'active' } + : node, + ), + } +} + +function normalizeAvailability(nodes: readonly WorkflowNode[]): WorkflowNode[] { + return nodes.map((node) => { + if (node.status === 'passed' || node.status === 'failed') return structuredClone(node) + const available = node.dependsOnNodeIds.every((dependencyId) => isPassed(nodes, dependencyId)) + return { ...structuredClone(node), status: available ? 'active' : 'locked' } + }) +} + +function isPassed(nodes: readonly WorkflowNode[], nodeId: string) { + return nodes.find((node) => node.id === nodeId)?.status === 'passed' +} + +function assertDependenciesExist(nodes: readonly WorkflowNode[], dependencyIds: readonly string[]) { + const knownIds = new Set(nodes.map((node) => node.id)) + const unknownId = dependencyIds.find((id) => !knownIds.has(id)) + if (unknownId) throw new Error(`依赖节点不存在:${unknownId}`) + if (new Set(dependencyIds).size !== dependencyIds.length) throw new Error('依赖节点不能重复') +} + +function assertNodeCanRun(run: WorkflowRun, node: WorkflowNode) { + if (node.status !== 'active') throw new Error('目标节点当前不可执行') + if (!node.dependsOnNodeIds.every((id) => isPassed(run.nodes, id))) { + throw new Error('目标节点的前置依赖尚未完成') + } +} + +function phaseForRunningRole(role: WorkflowGenerationRole): WorkflowNode['phase'] { + if (role === 'character_candidates') return 'generating_character_candidates' + if (role === 'action_frame_candidates') return 'generating_action_candidates' + return 'generating_animation' +} + +function roleForRunningPhase(phase: WorkflowNode['phase']): WorkflowGenerationRole { + if (phase === 'generating_character_candidates') return 'character_candidates' + if (phase === 'generating_action_candidates') return 'action_frame_candidates' + if (phase === 'generating_animation') return 'animation' + throw new Error(`当前 phase 不是生成阶段:${phase}`) +} + +function isGeneratingPhase(node: WorkflowNode) { + return ( + node.phase === 'generating_character_candidates' || + node.phase === 'generating_action_candidates' || + node.phase === 'generating_animation' + ) +} + +function collectDescendantIds(nodes: readonly WorkflowNode[], rootId: string) { + const affected = new Set([rootId]) + let changed = true + while (changed) { + changed = false + for (const node of nodes) { + if (affected.has(node.id)) continue + if (node.dependsOnNodeIds.some((id) => affected.has(id))) { + affected.add(node.id) + changed = true + } + } + } + return affected +} + +function resetNode(node: WorkflowNode): WorkflowNode { + if (node.type === 'character') { + return { + ...node, + status: 'locked', + phase: 'configuring_character', + generations: [], + error: null, + selectedImageUrl: null, + } + } + return { + ...node, + status: 'locked', + phase: 'configuring_action', + generations: [], + error: null, + selectedFirstFrameUrl: null, + } +} + +function subscriptionKey(nodeId: string, taskId: string) { + return `${nodeId}:${taskId}` +} + +function nonEmpty(value: string, field: string) { + const normalized = value.trim() + if (!normalized) throw new Error(`${field} 不能为空`) + return normalized +} + +function ensurePositiveInteger(value: number, field: string) { + if (!Number.isInteger(value) || value <= 0) throw new Error(`${field} 必须是正整数`) +} + +function createBrowserSafeId() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function asError(cause: unknown) { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index b2ae950f..d07245c6 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,56 +1,9 @@ -import type { CreateWorkflowRunInput, WorkflowNode, WorkflowRun } from '@/entities' - -/** 更新工作流图中某个节点的业务数据。 */ -export interface UpdateWorkflowNodeInput { - nodeId: WorkflowNode['id'] - data: unknown -} - -/** 从指定节点重做;旧结果会被覆盖,不创建 Revision。 */ -export interface RestartWorkflowFromNodeInput { - nodeId: WorkflowNode['id'] -} - -/** 把某次服务端调用的结果写回目标节点。 */ -export interface ApplyServerResultInput { - nodeId: WorkflowNode['id'] - /** 必须仍是目标节点当前关联的任务,防止重做前的晚到结果覆盖新结果。 */ - taskId: string - result: unknown -} - -/** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一张节点图:手动模式由用户逐个推进,Quick Start 自动连续推进。 - * - * 节点和边由前端管理;服务端提供生成能力,并原样持久化 WorkflowRun.nodes。 - * 节点能否推进由 dependsOnNodeIds 指向的前置节点状态决定,不依赖数组位置。 - */ -export interface WorkflowController { - /** 初始化一条节点图。 */ - create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程。 */ - getWorkflow(): WorkflowRun - - /** 推进指定节点;无依赖关系的多个 Action 节点可以并行。 */ - advanceNode(nodeId: WorkflowNode['id']): Promise - - /** 连续推进所有当前可用节点到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定节点的数据;页面不绕过 Controller 直接改流程状态。 */ - updateNode(input: UpdateWorkflowNodeInput): Promise - - /** - * 把服务端返回的结果写回目标节点。 - * taskId 已不再属于目标节点时丢弃结果,避免旧请求污染重做后的状态。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** 从指定节点重做并覆盖其旧结果;后端不提供 Revision 历史。 */ - restartFromNode(input: RestartWorkflowFromNodeInput): Promise - - /** 用户主动停止自动推进;已完成节点保留,不等于失败或完成。 */ - interrupt(): Promise -} +export { createWorkflowController } from './controller' +export type { + AddActionInput, + ApplyGenerationResultInput, + CreateWorkflowControllerOptions, + GenerateActionOptions, + GenerateCharacterOptions, + WorkflowController, +} from './controller' From 3666fdbabeab78df8e79e822e44f23c6c78bd8f2 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:52:19 +0800 Subject: [PATCH 2/9] refactor(workflow-controller): align five-node graph --- frontend/src/entities/index.ts | 7 +- frontend/src/entities/workflow-run/README.md | 6 +- .../src/entities/workflow-run/api.test.ts | 128 +++- frontend/src/entities/workflow-run/api.ts | 141 +++-- .../src/entities/workflow-run/constants.ts | 32 +- frontend/src/entities/workflow-run/index.ts | 39 +- .../features/workflow-controller/README.md | 1 + .../workflow-controller/controller.test.ts | 587 ++++++++++++------ .../workflow-controller/controller.ts | 315 +++++++--- 9 files changed, 877 insertions(+), 379 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 4125ea91..0a256f46 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -56,9 +56,12 @@ export type { MediaReference } from './media' /* 工作流 —— 前端管理节点,后端只持久化完整 nodes 文档 */ export { workflowRunApis } from './workflow-run' export type { - ActionWorkflowNode, - CharacterWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, CreateWorkflowRunInput, + ReviewWorkflowNode, WorkflowActionInput, WorkflowCharacterInput, WorkflowGenerationRef, diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 589d6330..40e7b31d 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -6,9 +6,11 @@ - 前后端统一使用 `WorkflowNode`。原先前端的 Step 与后端的 Node 是同一概念,已经合并。 - `WorkflowRun.nodes` 直接保存真实节点,不再使用 `root.steps` 或人为包装的根节点。 -- 一个节点与 Workflow Editor 中一张卡片一一对应;生成与选择是节点内部 phase,不拆成额外节点。 +- 五类节点与 Workflow Editor 的五类卡片一一对应:角色设定、角色母版、动作首帧、完整动画和审核。 +- “提交中、生成中、选择中”仍是节点内部 phase,不再拆成 Step 或额外任务节点。 - 节点通过 `dependsOnNodeIds` 保存直接前置依赖,因此边会与节点一起落库,不再依赖数组顺序猜测连线。 -- 多个 Action 节点可以依赖同一个角色节点;前置节点通过后即可并行,不互相阻塞。 +- 每个 Action 使用 `action-first-frame -> action-full-frame -> review` 三节点链;多条链共同依赖 + `character-template`,角色母版通过后即可并行,不互相阻塞。 - Quick Start 与 Workflow Editor 是两种独立界面,但推进同一张节点图,核心数据不区分 `ai/manual driver`。 - 后端不提供 Revision 历史。重做时覆盖旧结果,并用 `nodeId + taskId` 防止旧请求串线。 diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index 51bb1bc3..21559510 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -3,37 +3,53 @@ import type { WorkflowNode } from './index' const nodes: WorkflowNode[] = [ { - id: 'character-node', - type: 'character', + id: 'setup-node', + type: 'character-setup', status: 'passed', phase: 'completed', dependsOnNodeIds: [], - generations: [{ taskId: '91', role: 'character_candidates' }], + generations: [], error: null, input: { prompt: '一个像素骑士', referenceMedia: [] }, + }, + { + id: 'template-node', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['setup-node'], + generations: [{ taskId: '91', role: 'character_template' }], + error: null, selectedImageUrl: 'https://cdn.windup.test/character.png', }, { - id: 'walk-node', - type: 'action', - status: 'active', - phase: 'generating_animation', - dependsOnNodeIds: ['character-node'], - generations: [{ taskId: '92', role: 'animation' }], + id: 'walk-first-frame', + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['template-node'], + generations: [{ taskId: '92', role: 'first_frame' }], error: null, input: { outfitId: 'outfit-1', name: '行走', type: 'walk', prompt: null, fps: 12 }, selectedFirstFrameUrl: 'https://cdn.windup.test/walk-first.png', }, { - id: 'jump-node', - type: 'action', + id: 'walk-full-frame', + type: 'action-full-frame', status: 'active', - phase: 'generating_animation', - dependsOnNodeIds: ['character-node'], - generations: [{ taskId: '93', role: 'animation' }], + phase: 'generating', + dependsOnNodeIds: ['walk-first-frame'], + generations: [{ taskId: '93', role: 'complete_animation' }], + error: null, + }, + { + id: 'walk-review', + type: 'review', + status: 'locked', + phase: 'reviewing', + dependsOnNodeIds: ['walk-full-frame'], + generations: [], error: null, - input: { outfitId: 'outfit-1', name: '跳跃', type: 'jump', prompt: null, fps: 12 }, - selectedFirstFrameUrl: 'https://cdn.windup.test/jump-first.png', }, ] @@ -64,6 +80,74 @@ function jsonResponse(data: unknown) { } describe('workflowRunApis', () => { + it('hydrates the five visible workflow nodes with explicit dependency edges', async () => { + const fiveNodeDto = { + ...workflowRunDto, + nodes: [ + { + id: 'setup-1', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: 'pixel knight', referenceMedia: [] }, + }, + { + id: 'template-1', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['setup-1'], + generations: [{ taskId: 'task-template', role: 'character_template' }], + error: null, + selectedImageUrl: 'https://img/knight.png', + }, + { + id: 'first-frame-1', + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['template-1'], + generations: [{ taskId: 'task-frame', role: 'first_frame' }], + error: null, + input: { outfitId: 'outfit-1', name: 'walk', type: 'walk', prompt: null, fps: 12 }, + selectedFirstFrameUrl: 'https://img/walk-first.png', + }, + { + id: 'full-frame-1', + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['first-frame-1'], + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + error: null, + }, + { + id: 'review-1', + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: ['full-frame-1'], + generations: [], + error: null, + }, + ], + } + const apis = await loadWorkflowRunApis(async () => jsonResponse(fiveNodeDto)) + + await expect(apis.get('17')).resolves.toMatchObject({ + nodes: [ + { type: 'character-setup', dependsOnNodeIds: [] }, + { type: 'character-template', dependsOnNodeIds: ['setup-1'] }, + { type: 'action-first-frame', dependsOnNodeIds: ['template-1'] }, + { type: 'action-full-frame', dependsOnNodeIds: ['first-frame-1'] }, + { type: 'review', dependsOnNodeIds: ['full-frame-1'] }, + ], + }) + }) + it('persists frontend nodes directly without a synthetic root node', async () => { let request: Request | undefined const apis = await loadWorkflowRunApis(async (input, init) => { @@ -138,7 +222,7 @@ describe('workflowRunApis', () => { jsonResponse({ ...workflowRunDto, nodes: nodes.map((node) => - node.id === 'walk-node' ? { ...node, dependsOnNodeIds: ['missing-node'] } : node, + node.id === 'walk-full-frame' ? { ...node, dependsOnNodeIds: ['missing-node'] } : node, ), }), ) @@ -153,7 +237,7 @@ describe('workflowRunApis', () => { jsonResponse({ ...workflowRunDto, nodes: nodes.map((node) => - node.id === 'character-node' ? { ...node, dependsOnNodeIds: ['walk-node'] } : node, + node.id === 'setup-node' ? { ...node, dependsOnNodeIds: ['walk-review'] } : node, ), }), ) @@ -166,7 +250,7 @@ describe('workflowRunApis', () => { it('accepts an action-only graph for adding an action to an existing character', async () => { const actionOnlyDto = { ...workflowRunDto, - nodes: [{ ...nodes[1], dependsOnNodeIds: [] }], + nodes: [{ ...nodes[2], dependsOnNodeIds: [] }], } const apis = await loadWorkflowRunApis(async () => jsonResponse(actionOnlyDto)) await expect(apis.get('17')).resolves.toMatchObject({ nodes: actionOnlyDto.nodes }) @@ -174,13 +258,13 @@ describe('workflowRunApis', () => { it('rejects completed nodes that lost their selected asset', async () => { const completedActionWithoutSelection = { - ...nodes[1], + ...nodes[2], status: 'passed' as const, phase: 'completed' as const, selectedFirstFrameUrl: null, } const apis = await loadWorkflowRunApis(async () => - jsonResponse({ ...workflowRunDto, nodes: [nodes[0], completedActionWithoutSelection] }), + jsonResponse({ ...workflowRunDto, nodes: [completedActionWithoutSelection] }), ) await expect(apis.get('17')).rejects.toMatchObject({ name: 'ApiError', @@ -190,7 +274,7 @@ describe('workflowRunApis', () => { it('rejects a completed character node that lost its selected image', async () => { const completedCharacterWithoutSelection = { - ...nodes[0], + ...nodes[1], selectedImageUrl: null, } const apis = await loadWorkflowRunApis(async () => diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index 2e64314b..b9e009b7 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -1,7 +1,10 @@ import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' import type { - ActionWorkflowNode, - CharacterWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, + ReviewWorkflowNode, WorkflowNode, WorkflowRun, WorkflowRunApis, @@ -57,70 +60,116 @@ function hasValidCommonNodeFields(value: Record): boolean { ) { return false } + if (value.status === 'passed' ? value.phase !== 'completed' : value.phase === 'completed') { + return false + } return value.status === 'failed' ? typeof value.error === 'string' && value.error.trim().length > 0 : value.error === null } -function isCharacterNode(value: unknown): value is CharacterWorkflowNode { - if (!isRecord(value) || value.type !== 'character' || !hasValidCommonNodeFields(value)) { - return false - } - if ( - ![ - 'configuring_character', - 'generating_character_candidates', - 'selecting_character', - 'completed', - ].includes(String(value.phase)) || - !isRecord(value.input) - ) { - return false - } +function hasOnlyGenerationRole(value: Record, role: string | null): boolean { + if (!Array.isArray(value.generations)) return false + if (role === null) return value.generations.length === 0 + const refs = value.generations.filter(isRecord) + return ( + refs.length === value.generations.length && + refs.every((reference) => reference.role === role) && + new Set(refs.map((reference) => reference.taskId)).size === refs.length + ) +} + +function hasValidCharacterInput(value: unknown): boolean { + if (!isRecord(value)) return false + return ( + typeof value.prompt === 'string' && + Array.isArray(value.referenceMedia) && + value.referenceMedia.every((item) => typeof item === 'string') + ) +} + +function hasValidActionInput(value: unknown): boolean { + if (!isRecord(value)) return false return ( - typeof value.input.prompt === 'string' && - Array.isArray(value.input.referenceMedia) && - value.input.referenceMedia.every((item) => typeof item === 'string') && + typeof value.outfitId === 'string' && + value.outfitId.length > 0 && + typeof value.name === 'string' && + value.name.length > 0 && + typeof value.type === 'string' && + value.type.length > 0 && + isNullableString(value.prompt) && + typeof value.fps === 'number' && + Number.isFinite(value.fps) && + value.fps > 0 + ) +} + +function isCharacterSetupNode(value: unknown): value is CharacterSetupWorkflowNode { + return ( + isRecord(value) && + value.type === 'character-setup' && + hasValidCommonNodeFields(value) && + ['configuring', 'completed'].includes(String(value.phase)) && + hasValidCharacterInput(value.input) && + hasOnlyGenerationRole(value, null) + ) +} + +function isCharacterTemplateNode(value: unknown): value is CharacterTemplateWorkflowNode { + return ( + isRecord(value) && + value.type === 'character-template' && + hasValidCommonNodeFields(value) && + ['ready', 'generating', 'selecting', 'completed'].includes(String(value.phase)) && + hasOnlyGenerationRole(value, 'character_template') && isNullableString(value.selectedImageUrl) && (value.phase !== 'completed' || (typeof value.selectedImageUrl === 'string' && value.selectedImageUrl.length > 0)) ) } -function isActionNode(value: unknown): value is ActionWorkflowNode { - if (!isRecord(value) || value.type !== 'action' || !hasValidCommonNodeFields(value)) return false - if ( - ![ - 'configuring_action', - 'generating_action_candidates', - 'selecting_action_frame', - 'generating_animation', - 'reviewing_animation', - 'completed', - ].includes(String(value.phase)) || - !isRecord(value.input) - ) { - return false - } +function isActionFirstFrameNode(value: unknown): value is ActionFirstFrameWorkflowNode { return ( - typeof value.input.outfitId === 'string' && - value.input.outfitId.length > 0 && - typeof value.input.name === 'string' && - value.input.name.length > 0 && - typeof value.input.type === 'string' && - value.input.type.length > 0 && - isNullableString(value.input.prompt) && - typeof value.input.fps === 'number' && - Number.isFinite(value.input.fps) && - value.input.fps > 0 && + isRecord(value) && + value.type === 'action-first-frame' && + hasValidCommonNodeFields(value) && + ['configuring', 'generating', 'selecting', 'completed'].includes(String(value.phase)) && + hasValidActionInput(value.input) && + hasOnlyGenerationRole(value, 'first_frame') && isNullableString(value.selectedFirstFrameUrl) && (value.phase !== 'completed' || (typeof value.selectedFirstFrameUrl === 'string' && value.selectedFirstFrameUrl.length > 0)) ) } +function isActionFullFrameNode(value: unknown): value is ActionFullFrameWorkflowNode { + return ( + isRecord(value) && + value.type === 'action-full-frame' && + hasValidCommonNodeFields(value) && + ['ready', 'generating', 'completed'].includes(String(value.phase)) && + hasOnlyGenerationRole(value, 'complete_animation') + ) +} + +function isReviewNode(value: unknown): value is ReviewWorkflowNode { + return ( + isRecord(value) && + value.type === 'review' && + hasValidCommonNodeFields(value) && + ['reviewing', 'completed'].includes(String(value.phase)) && + hasOnlyGenerationRole(value, null) + ) +} + function isWorkflowNode(value: unknown): value is WorkflowNode { - return isCharacterNode(value) || isActionNode(value) + return ( + isCharacterSetupNode(value) || + isCharacterTemplateNode(value) || + isActionFirstFrameNode(value) || + isActionFullFrameNode(value) || + isReviewNode(value) + ) } function isAcyclicNodeGraph(nodes: readonly WorkflowNode[]): boolean { diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts index e8ba9c9c..f7a20379 100644 --- a/frontend/src/entities/workflow-run/constants.ts +++ b/frontend/src/entities/workflow-run/constants.ts @@ -3,25 +3,29 @@ /** 后端资源状态只表达是否被软删除,不等同于前端节点状态。 */ export const WORKFLOW_RUN_STORAGE_STATUSES = ['active', 'soft_deleted'] as const -/** WorkflowNode 与 Workflow Editor 中用户看到的卡片一一对应。 */ -export const WORKFLOW_NODE_TYPES = ['character', 'action'] as const +/** WorkflowNode 与 Workflow Editor 中用户看到的五类卡片一一对应。 */ +export const WORKFLOW_NODE_TYPES = [ + 'character-setup', + 'character-template', + 'action-first-frame', + 'action-full-frame', + 'review', +] as const export const WORKFLOW_NODE_STATUSES = ['locked', 'active', 'passed', 'failed'] as const -/** phase 描述节点内部状态,不把“生成”和“选择”拆成额外节点。 */ +/** phase 只描述一张卡片内部的进度;节点之间的先后关系由显式边表达。 */ export const WORKFLOW_NODE_PHASES = [ - 'configuring_character', - 'generating_character_candidates', - 'selecting_character', - 'configuring_action', - 'generating_action_candidates', - 'selecting_action_frame', - 'generating_animation', - 'reviewing_animation', + 'configuring', + 'ready', + 'generating', + 'selecting', + 'reviewing', 'completed', ] as const +/** 与 Generation.type 使用同一组词,避免恢复任务时再做第二套名称转换。 */ export const WORKFLOW_GENERATION_ROLES = [ - 'character_candidates', - 'action_frame_candidates', - 'animation', + 'character_template', + 'first_frame', + 'complete_animation', ] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 7b51b324..b8a5d6b3 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -40,10 +40,17 @@ export interface WorkflowCharacterInput { referenceMedia: readonly MediaReference[] } -/** 角色节点内部完成资料填写、候选图生成和候选确认。 */ -export interface CharacterWorkflowNode extends WorkflowNodeBase { - type: 'character' +/** 角色资料卡片;只保存用户输入,不承担图片生成。 */ +export interface CharacterSetupWorkflowNode extends WorkflowNodeBase { + type: 'character-setup' + phase: 'configuring' | 'completed' input: WorkflowCharacterInput +} + +/** 角色母版卡片;生成候选图并保存用户最终确认的母版。 */ +export interface CharacterTemplateWorkflowNode extends WorkflowNodeBase { + type: 'character-template' + phase: 'ready' | 'generating' | 'selecting' | 'completed' selectedImageUrl: string | null } @@ -55,15 +62,33 @@ export interface WorkflowActionInput { fps: number } -/** 一个 Action 对应一个节点;共同依赖同一节点的多个 Action 可以并行。 */ -export interface ActionWorkflowNode extends WorkflowNodeBase { - type: 'action' +/** Action 的首帧卡片;每个 Action 都必须有一份独立输入和确认结果。 */ +export interface ActionFirstFrameWorkflowNode extends WorkflowNodeBase { + type: 'action-first-frame' + phase: 'configuring' | 'generating' | 'selecting' | 'completed' input: WorkflowActionInput selectedFirstFrameUrl: string | null } +/** 基于已确认首帧生成完整动画。 */ +export interface ActionFullFrameWorkflowNode extends WorkflowNodeBase { + type: 'action-full-frame' + phase: 'ready' | 'generating' | 'completed' +} + +/** 只负责核验完整动画;审核通过不等于下载或导出。 */ +export interface ReviewWorkflowNode extends WorkflowNodeBase { + type: 'review' + phase: 'reviewing' | 'completed' +} + /** 工作流图中的真实节点。前端和后端统一使用 node,不再保留 step 或假 root。 */ -export type WorkflowNode = CharacterWorkflowNode | ActionWorkflowNode +export type WorkflowNode = + | CharacterSetupWorkflowNode + | CharacterTemplateWorkflowNode + | ActionFirstFrameWorkflowNode + | ActionFullFrameWorkflowNode + | ReviewWorkflowNode /** * 一次制作流程的持久化容器。Quick Start 与 Workflow Editor 只是不同界面; diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index 5d779610..02226834 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -15,6 +15,7 @@ - `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 - Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。 +- 新增 Action 一次创建首帧、完整动画和审核三节点,不会遗漏首帧或用数组位置猜关系。 - Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 - WorkflowRun 只有在后端 `update` 成功后才替换内存快照,保存失败不会向页面假报成功。 - Generation 已创建但任务引用暂时保存失败时,本实例会保留待附加记录;重试同一命令或 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 97fd2809..bead32bc 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import type { - CharacterWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, Generation, GenerationApis, GenerationEvent, + ReviewWorkflowNode, WorkflowActionInput, WorkflowNode, WorkflowRun, @@ -12,16 +16,33 @@ import type { } from '@/entities' import { createWorkflowController } from '.' -function characterNode(overrides: Partial = {}): CharacterWorkflowNode { +function setupNode( + overrides: Partial = {}, +): CharacterSetupWorkflowNode { return { - id: 'character-1', - type: 'character', + id: 'setup-1', + type: 'character-setup', status: 'active', - phase: 'configuring_character', + phase: 'configuring', dependsOnNodeIds: [], generations: [], error: null, input: { prompt: '像素骑士', referenceMedia: [] }, + ...overrides, + } +} + +function templateNode( + overrides: Partial = {}, +): CharacterTemplateWorkflowNode { + return { + id: 'template-1', + type: 'character-template', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['setup-1'], + generations: [], + error: null, selectedImageUrl: null, ...overrides, } @@ -38,7 +59,71 @@ function actionInput(overrides: Partial = {}): WorkflowActi } } -function createRun(nodes: WorkflowNode[] = [characterNode()]): WorkflowRun { +function firstFrameNode( + overrides: Partial = {}, +): ActionFirstFrameWorkflowNode { + return { + id: 'action-walk', + type: 'action-first-frame', + status: 'active', + phase: 'configuring', + dependsOnNodeIds: ['template-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + ...overrides, + } +} + +function fullFrameNode( + overrides: Partial = {}, +): ActionFullFrameWorkflowNode { + return { + id: 'action-walk:full-frame', + type: 'action-full-frame', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['action-walk'], + generations: [], + error: null, + ...overrides, + } +} + +function reviewNode(overrides: Partial = {}): ReviewWorkflowNode { + return { + id: 'action-walk:review', + type: 'review', + status: 'locked', + phase: 'reviewing', + dependsOnNodeIds: ['action-walk:full-frame'], + generations: [], + error: null, + ...overrides, + } +} + +function characterNodes(): WorkflowNode[] { + return [setupNode(), templateNode()] +} + +function completedCharacterNodes(): WorkflowNode[] { + return [ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + ] +} + +function actionNodes(): WorkflowNode[] { + return [firstFrameNode(), fullFrameNode(), reviewNode()] +} + +function createRun(nodes: WorkflowNode[] = characterNodes()): WorkflowRun { return { id: 'run-1', projectId: '1', @@ -128,6 +213,21 @@ function createController(run = createRun()) { return { controller, workflow, generation, asyncErrors } } +function completedAnimationEvent(taskId = 'task-2'): GenerationEvent { + return { + taskId, + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: Array.from({ length: 32 }, (_, index) => ({ + url: `https://img/frame-${index}.png`, + })), + }, + error: null, + } +} + async function flushAsyncWork() { await new Promise((resolve) => setTimeout(resolve, 0)) } @@ -142,65 +242,91 @@ describe('WorkflowController', () => { onAsyncError: vi.fn(), }) - const created = await controller.create({ projectId: '1', nodes: [characterNode()] }) + const created = await controller.create({ projectId: '1', nodes: characterNodes() }) expect(controller.getWorkflow()).toEqual(created) await expect( - controller.create({ projectId: '2', nodes: [characterNode({ id: 'other' })] }), + controller.create({ + projectId: '2', + nodes: [setupNode({ id: 'other-setup' }), templateNode({ id: 'other-template' })], + }), ).rejects.toThrow('已经绑定') }) - it('角色通过后按显式依赖边同时解锁多个 Action', async () => { - const run = createRun([ - characterNode({ phase: 'selecting_character' }), + it('adds a complete first-frame, full-frame, and review chain for one Action', async () => { + const { controller } = createController(createRun(completedCharacterNodes())) + + const next = await controller.addAction({ nodeId: 'action-walk', input: actionInput() }) + + expect(next.nodes.slice(2)).toMatchObject([ { id: 'action-walk', - type: 'action', + type: 'action-first-frame', + status: 'active', + dependsOnNodeIds: ['template-1'], + }, + { + id: 'action-walk:full-frame', + type: 'action-full-frame', status: 'locked', - phase: 'configuring_action', - dependsOnNodeIds: ['character-1'], - generations: [], - error: null, - input: actionInput(), - selectedFirstFrameUrl: null, + dependsOnNodeIds: ['action-walk'], }, { + id: 'action-walk:review', + type: 'review', + status: 'locked', + dependsOnNodeIds: ['action-walk:full-frame'], + }, + ]) + }) + + it('角色母版通过后按显式边同时解锁多个 Action 首帧节点', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ status: 'active', phase: 'selecting' }), + firstFrameNode({ id: 'action-walk', status: 'locked' }), + firstFrameNode({ id: 'action-jump', - type: 'action', status: 'locked', - phase: 'configuring_action', - dependsOnNodeIds: ['character-1'], - generations: [], - error: null, input: actionInput({ name: '跳跃', type: 'jump' }), - selectedFirstFrameUrl: null, - }, + }), ]) const { controller } = createController(run) - const next = await controller.confirmCharacter('character-1', 'https://img/knight.png') + const next = await controller.confirmCharacter('template-1', 'https://img/knight.png') expect(next.nodes).toEqual( expect.arrayContaining([ - expect.objectContaining({ id: 'character-1', status: 'passed', phase: 'completed' }), + expect.objectContaining({ id: 'template-1', status: 'passed', phase: 'completed' }), expect.objectContaining({ id: 'action-walk', status: 'active' }), expect.objectContaining({ id: 'action-jump', status: 'active' }), ]), ) }) - it('角色生成任务落库并从终态事件进入候选确认阶段', async () => { + it('提交角色设定后在母版节点记录任务并进入候选选择', async () => { const { controller, workflow, generation, asyncErrors } = createController() - await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + expect(generation.apis.create).toHaveBeenCalledWith( - expect.objectContaining({ spriteWidth: 64, spriteHeight: 64 }), + expect.objectContaining({ + type: 'character_template', + prompt: '像素骑士', + spriteWidth: 64, + spriteHeight: 64, + }), + ) + expect(workflow.getSaved().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'setup-1', status: 'passed', phase: 'completed' }), + expect.objectContaining({ + id: 'template-1', + phase: 'generating', + generations: [{ taskId: 'task-1', role: 'character_template' }], + }), + ]), ) - const inFlight = workflow.getSaved().nodes[0] - expect(inFlight).toMatchObject({ - phase: 'generating_character_candidates', - generations: [{ taskId: 'task-1', role: 'character_candidates' }], - }) generation.emit({ taskId: 'task-1', @@ -214,14 +340,47 @@ describe('WorkflowController', () => { }) await flushAsyncWork() - expect(controller.getWorkflow().nodes[0]).toMatchObject({ + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + type: 'character-template', status: 'active', - phase: 'selecting_character', + phase: 'selecting', error: null, }) expect(asyncErrors).toEqual([]) }) + it('角色设定已落库但生成请求失败后可以重试', async () => { + const { controller, generation } = createController() + vi.mocked(generation.apis.create).mockRejectedValueOnce(new Error('生成服务暂时不可用')) + + await expect( + controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('生成服务暂时不可用') + await expect( + controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }), + ).resolves.toMatchObject({ + nodes: expect.arrayContaining([ + expect.objectContaining({ + id: 'template-1', + phase: 'generating', + generations: [{ taskId: 'task-1', role: 'character_template' }], + }), + ]), + }) + }) + + it('角色设定并发提交只创建一个母版生成任务', async () => { + const { controller, generation } = createController() + const options = { spriteWidth: 64, spriteHeight: 64 } + + await Promise.all([ + controller.generateCharacter('setup-1', options), + controller.generateCharacter('setup-1', options), + ]) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + }) + it('SSE 与紧随其后的查询同时返回终态时只保存一次结果', async () => { const workflow = createWorkflowApis() const terminalEvent: GenerationEvent = { @@ -263,18 +422,15 @@ describe('WorkflowController', () => { onAsyncError: vi.fn(), }) - await controller.generateCharacter('character-1', { - spriteWidth: 64, - spriteHeight: 64, - }) + await controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }) - expect(workflow.apis.update).toHaveBeenCalledTimes(2) - expect(controller.getWorkflow().nodes[0].phase).toBe('selecting_character') + expect(workflow.apis.update).toHaveBeenCalledTimes(3) + expect(controller.getWorkflow().nodes[1].phase).toBe('selecting') }) it('中断后忽略迟到结果,恢复时查询终态再推进', async () => { const { controller, generation } = createController() - await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }) await controller.interrupt() generation.emit({ @@ -288,35 +444,27 @@ describe('WorkflowController', () => { error: null, }) await flushAsyncWork() - expect(controller.getWorkflow().nodes[0].phase).toBe('generating_character_candidates') + expect(controller.getWorkflow().nodes[1].phase).toBe('generating') await controller.resume() - expect(controller.getWorkflow().nodes[0].phase).toBe('selecting_character') + expect(controller.getWorkflow().nodes[1].phase).toBe('selecting') }) - it('从节点重做会清掉下游和旧 task,旧事件不能覆盖新执行线', async () => { + it('从母版节点重做会清空下游任务,旧事件不能覆盖新执行线', async () => { const run = createRun([ - characterNode({ - phase: 'generating_character_candidates', - generations: [{ taskId: 'task-old', role: 'character_candidates' }], + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-old', role: 'character_template' }], }), - { - id: 'action-walk', - type: 'action', - status: 'locked', - phase: 'configuring_action', - dependsOnNodeIds: ['character-1'], - generations: [], - error: null, - input: actionInput(), - selectedFirstFrameUrl: null, - }, + ...actionNodes().map((node) => ({ ...node, status: 'locked' as const })), ]) const { controller } = createController(run) - await controller.restartFromNode('character-1') + await controller.restartFromNode('template-1') await controller.applyGenerationResult({ - nodeId: 'character-1', + nodeId: 'template-1', taskId: 'task-old', generation: { id: 'task-old', @@ -331,19 +479,27 @@ describe('WorkflowController', () => { }, }) - expect(controller.getWorkflow().nodes).toEqual([ - expect.objectContaining({ - id: 'character-1', - status: 'active', - phase: 'configuring_character', - generations: [], - }), - expect.objectContaining({ id: 'action-walk', status: 'locked', generations: [] }), - ]) + expect(controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'template-1', + status: 'active', + phase: 'ready', + generations: [], + }), + expect.objectContaining({ id: 'action-walk', status: 'locked', generations: [] }), + expect.objectContaining({ + id: 'action-walk:full-frame', + status: 'locked', + generations: [], + }), + ]), + ) }) it('生成请求尚未返回时重做,旧任务不能挂回新执行线', async () => { - const workflow = createWorkflowApis() + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) + const workflow = createWorkflowApis(run) const pendingResolvers: Array<(generation: Generation) => void> = [] const snapshots = new Map() const createGeneration = vi.fn( @@ -361,22 +517,22 @@ describe('WorkflowController', () => { subscribe: vi.fn(() => () => undefined), } const controller = createWorkflowController({ - workflow: createRun(), + workflow: run, workflowRunApis: workflow.apis, generationApis, onAsyncError: vi.fn(), }) - const oldSubmission = controller.generateCharacter('character-1', { - spriteWidth: 64, - spriteHeight: 64, + const oldSubmission = controller.generateActionFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], }) await Promise.resolve() - await controller.restartFromNode('character-1') + await controller.restartFromNode('action-walk') - const newSubmission = controller.generateCharacter('character-1', { - spriteWidth: 64, - spriteHeight: 64, + const newSubmission = controller.generateActionFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], }) await Promise.resolve() expect(createGeneration).toHaveBeenCalledTimes(2) @@ -384,18 +540,83 @@ describe('WorkflowController', () => { pendingResolvers[0]?.({ id: 'task-old', projectId: '1', - type: 'character_template', + type: 'first_frame', status: 'pending', result: null, error: null, }) await oldSubmission - const sameNewSubmission = controller.generateCharacter('character-1', { + const sameNewSubmission = controller.generateActionFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }) + expect(createGeneration).toHaveBeenCalledTimes(2) + + pendingResolvers[1]?.({ + id: 'task-new', + projectId: '1', + type: 'first_frame', + status: 'pending', + result: null, + error: null, + }) + await Promise.all([newSubmission, sameNewSubmission]) + + expect(controller.getWorkflow().nodes[2].generations).toEqual([ + { taskId: 'task-new', role: 'first_frame' }, + ]) + }) + + it('角色母版请求尚未返回时从设定重做,新提交不会复用旧命令', async () => { + const run = createRun() + const workflow = createWorkflowApis(run) + const pendingResolvers: Array<(generation: Generation) => void> = [] + const snapshots = new Map() + const createGeneration = vi.fn( + () => + new Promise((resolve) => { + pendingResolvers.push((generation) => { + snapshots.set(generation.id, generation) + resolve(generation) + }) + }), + ) as unknown as GenerationApis['create'] + const generationApis: GenerationApis = { + create: createGeneration, + get: vi.fn(async (_projectId, id) => structuredClone(snapshots.get(id)!)), + subscribe: vi.fn(() => () => undefined), + } + const controller = createWorkflowController({ + workflow: run, + workflowRunApis: workflow.apis, + generationApis, + onAsyncError: vi.fn(), + }) + + const oldSubmission = controller.generateCharacter('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + await flushAsyncWork() + expect(createGeneration).toHaveBeenCalledTimes(1) + + await controller.restartFromNode('setup-1') + const newSubmission = controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64, }) + await flushAsyncWork() expect(createGeneration).toHaveBeenCalledTimes(2) + pendingResolvers[0]?.({ + id: 'task-old', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + await oldSubmission pendingResolvers[1]?.({ id: 'task-new', projectId: '1', @@ -404,126 +625,123 @@ describe('WorkflowController', () => { result: null, error: null, }) - await Promise.all([newSubmission, sameNewSubmission]) + await newSubmission - expect(controller.getWorkflow().nodes[0].generations).toEqual([ - { taskId: 'task-new', role: 'character_candidates' }, - ]) + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + id: 'template-1', + phase: 'generating', + generations: [{ taskId: 'task-new', role: 'character_template' }], + }) }) it('保存失败时不发布未落库的新状态', async () => { - const { controller, workflow } = createController( - createRun([characterNode({ phase: 'selecting_character' })]), - ) + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ status: 'active', phase: 'selecting' }), + ]) + const { controller, workflow } = createController(run) vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) await expect( - controller.confirmCharacter('character-1', 'https://img/knight.png'), + controller.confirmCharacter('template-1', 'https://img/knight.png'), ).rejects.toThrow('后端保存失败') - expect(controller.getWorkflow().nodes[0]).toMatchObject({ + expect(controller.getWorkflow().nodes[1]).toMatchObject({ status: 'active', - phase: 'selecting_character', + phase: 'selecting', selectedImageUrl: null, }) }) it('生成任务创建成功但引用保存失败时,重试复用同一个任务', async () => { - const { controller, workflow, generation } = createController() + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) + const { controller, workflow, generation } = createController(run) vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) await expect( - controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + controller.generateActionFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }), ).rejects.toThrow('后端保存失败') - expect(controller.getWorkflow().nodes[0].generations).toEqual([]) + expect(controller.getWorkflow().nodes[2].generations).toEqual([]) - await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateActionFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }) expect(generation.apis.create).toHaveBeenCalledTimes(1) - expect(controller.getWorkflow().nodes[0]).toMatchObject({ - phase: 'generating_character_candidates', - generations: [{ taskId: 'task-1', role: 'character_candidates' }], + expect(controller.getWorkflow().nodes[2]).toMatchObject({ + phase: 'generating', + generations: [{ taskId: 'task-1', role: 'first_frame' }], }) }) it('同一节点并发点击只创建一个生成任务', async () => { - const { controller, generation } = createController() + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) + const { controller, generation } = createController(run) + const options = { characterId: 'character-1', referenceMedia: [] } await Promise.all([ - controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), - controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + controller.generateActionFrame('action-walk', options), + controller.generateActionFrame('action-walk', options), ]) expect(generation.apis.create).toHaveBeenCalledTimes(1) }) - it('完整动画必须是 32 帧,通过审核后节点才完成', async () => { - const frames = Array.from({ length: 32 }, (_, index) => ({ - url: `https://img/frame-${index}.png`, - })) + it('完整动画必须是 32 帧,完成后只解锁自己的审核节点', async () => { const run = createRun([ - characterNode({ + ...completedCharacterNodes(), + firstFrameNode({ status: 'passed', phase: 'completed', - selectedImageUrl: 'https://img/knight.png', + selectedFirstFrameUrl: 'https://img/first.png', }), - { - id: 'action-walk', - type: 'action', + fullFrameNode({ status: 'active', - phase: 'generating_animation', - dependsOnNodeIds: ['character-1'], - generations: [{ taskId: 'task-animation', role: 'animation' }], - error: null, - input: actionInput(), - selectedFirstFrameUrl: 'https://img/first.png', - }, + phase: 'generating', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode(), ]) const { controller } = createController(run) await controller.applyGenerationResult({ - nodeId: 'action-walk', + nodeId: 'action-walk:full-frame', taskId: 'task-animation', generation: { id: 'task-animation', projectId: '1', - type: 'complete_animation', - status: 'completed', - result: { type: 'complete_animation', frames }, - error: null, + ...completedAnimationEvent('task-animation'), }, }) - expect(controller.getWorkflow().nodes[1]).toMatchObject({ - status: 'active', - phase: 'reviewing_animation', - }) - await controller.approveAction('action-walk') - expect(controller.getWorkflow().nodes[1]).toMatchObject({ + expect(controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'action-walk:full-frame', + status: 'passed', + phase: 'completed', + }), + expect.objectContaining({ + id: 'action-walk:review', + status: 'active', + phase: 'reviewing', + }), + ]), + ) + + await controller.approveAction('action-walk:review') + expect(controller.getWorkflow().nodes[4]).toMatchObject({ status: 'passed', phase: 'completed', }) }) - it('同一 Action 节点依次生成首帧和 32 帧动画', async () => { - const run = createRun([ - characterNode({ - status: 'passed', - phase: 'completed', - selectedImageUrl: 'https://img/knight.png', - }), - { - id: 'action-walk', - type: 'action', - status: 'active', - phase: 'configuring_action', - dependsOnNodeIds: ['character-1'], - generations: [], - error: null, - input: actionInput(), - selectedFirstFrameUrl: null, - }, - ]) + it('一个 Action 依次使用独立的首帧、完整动画和审核节点', async () => { + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) const { controller, generation } = createController(run) await controller.generateActionFrame('action-walk', { @@ -540,24 +758,13 @@ describe('WorkflowController', () => { await flushAsyncWork() await controller.confirmActionFrame('action-walk', 'https://img/first.png') - await controller.generateAnimation('action-walk', { + await controller.generateAnimation('action-walk:full-frame', { characterId: 'character-backend-1', referenceMedia: [], }) - generation.emit({ - taskId: 'task-2', - type: 'complete_animation', - status: 'completed', - result: { - type: 'complete_animation', - frames: Array.from({ length: 32 }, (_, index) => ({ - url: `https://img/frame-${index}.png`, - })), - }, - error: null, - }) + generation.emit(completedAnimationEvent()) await flushAsyncWork() - await controller.approveAction('action-walk') + await controller.approveAction('action-walk:review') expect(generation.apis.create).toHaveBeenNthCalledWith( 1, @@ -574,37 +781,36 @@ describe('WorkflowController', () => { firstFrameUrl: 'https://img/first.png', }), ) - expect(controller.getWorkflow().nodes[1]).toMatchObject({ - status: 'passed', - phase: 'completed', - generations: [ - { taskId: 'task-1', role: 'action_frame_candidates' }, - { taskId: 'task-2', role: 'animation' }, - ], - }) + expect(controller.getWorkflow().nodes.slice(2)).toMatchObject([ + { + type: 'action-first-frame', + status: 'passed', + generations: [{ taskId: 'task-1', role: 'first_frame' }], + }, + { + type: 'action-full-frame', + status: 'passed', + generations: [{ taskId: 'task-2', role: 'complete_animation' }], + }, + { type: 'review', status: 'passed' }, + ]) }) - it('恢复动画阶段时不会让旧首帧任务把节点倒退', async () => { + it('恢复时只查询当前生成节点,不重复恢复已经通过的首帧任务', async () => { const run = createRun([ - characterNode({ + ...completedCharacterNodes(), + firstFrameNode({ status: 'passed', phase: 'completed', - selectedImageUrl: 'https://img/knight.png', + generations: [{ taskId: 'task-first-frame', role: 'first_frame' }], + selectedFirstFrameUrl: 'https://img/first.png', }), - { - id: 'action-walk', - type: 'action', + fullFrameNode({ status: 'active', - phase: 'generating_animation', - dependsOnNodeIds: ['character-1'], - generations: [ - { taskId: 'task-first-frame', role: 'action_frame_candidates' }, - { taskId: 'task-animation', role: 'animation' }, - ], - error: null, - input: actionInput(), - selectedFirstFrameUrl: 'https://img/first.png', - }, + phase: 'generating', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode(), ]) const { controller, generation } = createController(run) generation.snapshots.set('task-first-frame', { @@ -625,14 +831,9 @@ describe('WorkflowController', () => { }) await controller.resume() - await controller.applyGenerationResult({ - nodeId: 'action-walk', - taskId: 'task-first-frame', - generation: generation.snapshots.get('task-first-frame')!, - }) expect(generation.apis.get).toHaveBeenCalledTimes(1) expect(generation.apis.get).toHaveBeenCalledWith('1', 'task-animation') - expect(controller.getWorkflow().nodes[1].phase).toBe('generating_animation') + expect(controller.getWorkflow().nodes[3].phase).toBe('generating') }) }) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 2a17e403..87052e06 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,7 +1,9 @@ import type { - ActionWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, CharacterTemplateGenerationInput, - CharacterWorkflowNode, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, CompleteAnimationGenerationInput, CreateWorkflowRunInput, FirstFrameGenerationInput, @@ -9,6 +11,7 @@ import type { GenerationApis, GenerationEvent, MediaReference, + ReviewWorkflowNode, WorkflowActionInput, WorkflowGenerationRef, WorkflowGenerationRole, @@ -20,9 +23,9 @@ import type { const COMPLETE_ANIMATION_FRAME_COUNT = 32 export interface AddActionInput { - /** 未传时由 Controller 生成,仅用于前端节点图。 */ + /** 首帧节点 ID;完整动画和审核节点在此 ID 后追加稳定后缀。 */ nodeId?: WorkflowNode['id'] - /** 默认依赖当前图中的 Character 节点。 */ + /** 默认依赖当前图中已确认的角色母版节点。 */ dependsOnNodeIds?: readonly WorkflowNode['id'][] input: WorkflowActionInput } @@ -66,26 +69,26 @@ export interface WorkflowController { addAction(input: AddActionInput): Promise generateCharacter( - nodeId: CharacterWorkflowNode['id'], + nodeId: CharacterSetupWorkflowNode['id'], options: GenerateCharacterOptions, ): Promise confirmCharacter( - nodeId: CharacterWorkflowNode['id'], + nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, ): Promise generateActionFrame( - nodeId: ActionWorkflowNode['id'], + nodeId: ActionFirstFrameWorkflowNode['id'], options: GenerateActionOptions, ): Promise confirmActionFrame( - nodeId: ActionWorkflowNode['id'], + nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, ): Promise generateAnimation( - nodeId: ActionWorkflowNode['id'], + nodeId: ActionFullFrameWorkflowNode['id'], options: GenerateActionOptions, ): Promise - approveAction(nodeId: ActionWorkflowNode['id']): Promise + approveAction(nodeId: ReviewWorkflowNode['id']): Promise /** 刷新恢复时查询已记录的 Generation,再恢复 SSE。 */ resume(): Promise @@ -123,6 +126,7 @@ export function createWorkflowController({ let current = workflow ? structuredClone(workflow) : null let interrupted = false let saveQueue: Promise = Promise.resolve() + const characterCommands = new Map>() const submissions = new Map>() const subscriptions = new Map() const nodeEpochs = new Map() @@ -183,56 +187,111 @@ export function createWorkflowController({ function addAction({ nodeId = createId(), dependsOnNodeIds, input }: AddActionInput) { ensureRunning() return persist((run) => { - if (run.nodes.some((node) => node.id === nodeId)) { - throw new Error(`WorkflowNode 已存在:${nodeId}`) - } + const fullFrameId = `${nodeId}:full-frame` + const reviewId = `${nodeId}:review` + const newIds = [nodeId, fullFrameId, reviewId] + const duplicateId = newIds.find((id) => run.nodes.some((node) => node.id === id)) + if (duplicateId) throw new Error(`WorkflowNode 已存在:${duplicateId}`) const dependencies = dependsOnNodeIds ? [...dependsOnNodeIds] - : run.nodes.filter((node) => node.type === 'character').map((node) => node.id) + : run.nodes.filter((node) => node.type === 'character-template').map((node) => node.id) + if (dependencies.length === 0) throw new Error('新增 Action 前必须存在角色母版节点') assertDependenciesExist(run.nodes, dependencies) - const node: ActionWorkflowNode = { + const firstFrameNode: ActionFirstFrameWorkflowNode = { id: nodeId, - type: 'action', + type: 'action-first-frame', status: dependencies.every((id) => isPassed(run.nodes, id)) ? 'active' : 'locked', - phase: 'configuring_action', + phase: 'configuring', dependsOnNodeIds: dependencies, generations: [], error: null, input: structuredClone(input), selectedFirstFrameUrl: null, } - return { ...run, nodes: [...run.nodes, node] } + const fullFrameNode: ActionFullFrameWorkflowNode = { + id: fullFrameId, + type: 'action-full-frame', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: [firstFrameNode.id], + generations: [], + error: null, + } + const reviewNode: ReviewWorkflowNode = { + id: reviewId, + type: 'review', + status: 'locked', + phase: 'reviewing', + dependsOnNodeIds: [fullFrameNode.id], + generations: [], + error: null, + } + return { ...run, nodes: [...run.nodes, firstFrameNode, fullFrameNode, reviewNode] } }) } function generateCharacter( - nodeId: CharacterWorkflowNode['id'], + nodeId: CharacterSetupWorkflowNode['id'], options: GenerateCharacterOptions, - ) { + ): Promise { ensurePositiveInteger(options.spriteWidth, 'spriteWidth') ensurePositiveInteger(options.spriteHeight, 'spriteHeight') - return submitGeneration(nodeId, 'character_candidates', (run, node) => { - if (node.type !== 'character') throw new Error('目标节点不是 Character') - if (node.phase !== 'configuring_character') throw new Error('角色节点当前不能开始生成') + ensureRunning() + const active = characterCommands.get(nodeId) + if (active) return active + + const command = performCharacterGeneration(nodeId, options).finally(() => { + if (characterCommands.get(nodeId) === command) characterCommands.delete(nodeId) + }) + characterCommands.set(nodeId, command) + return command + } + + async function performCharacterGeneration( + nodeId: CharacterSetupWorkflowNode['id'], + options: GenerateCharacterOptions, + ): Promise { + const before = requireWorkflow() + const setupBefore = findNode(before, nodeId) + if (setupBefore.type !== 'character-setup') throw new Error('目标节点不是角色设定') + + const advanced = + setupBefore.status === 'passed' && setupBefore.phase === 'completed' + ? before + : await persist((run) => { + const setupNode = findNode(run, nodeId) + if (setupNode.type !== 'character-setup') throw new Error('目标节点不是角色设定') + if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') { + throw new Error('角色设定节点当前不能提交') + } + return unlockReadyNodes( + replaceNode(run, { ...setupNode, status: 'passed', phase: 'completed', error: null }), + ) + }) + const templateNode = findSingleDependentNode(advanced, nodeId, 'character-template') + return submitGeneration(templateNode.id, 'character_template', (run, node) => { + if (node.type !== 'character-template') throw new Error('目标节点不是角色母版') + if (node.phase !== 'ready') throw new Error('角色母版节点当前不能开始生成') + const setupNode = findSingleDependencyNode(run, node, 'character-setup') const input: CharacterTemplateGenerationInput = { type: 'character_template', projectId: run.projectId, - prompt: node.input.prompt, - referenceMedia: node.input.referenceMedia, + prompt: setupNode.input.prompt, + referenceMedia: setupNode.input.referenceMedia, ...options, } return input }) } - function confirmCharacter(nodeId: CharacterWorkflowNode['id'], selectedImageUrl: string) { + function confirmCharacter(nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string) { ensureRunning() const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') return persist((run) => updateNode(run, nodeId, (node) => { - if (node.type !== 'character') throw new Error('目标节点不是 Character') - if (node.status !== 'active' || node.phase !== 'selecting_character') { - throw new Error('角色节点当前不能确认候选图') + if (node.type !== 'character-template') throw new Error('目标节点不是角色母版') + if (node.status !== 'active' || node.phase !== 'selecting') { + throw new Error('角色母版节点当前不能确认候选图') } return unlockReadyNodes({ ...run, @@ -246,11 +305,14 @@ export function createWorkflowController({ ) } - function generateActionFrame(nodeId: ActionWorkflowNode['id'], options: GenerateActionOptions) { + function generateActionFrame( + nodeId: ActionFirstFrameWorkflowNode['id'], + options: GenerateActionOptions, + ) { const characterId = nonEmpty(options.characterId, 'characterId') - return submitGeneration(nodeId, 'action_frame_candidates', (run, node) => { - if (node.type !== 'action') throw new Error('目标节点不是 Action') - if (node.phase !== 'configuring_action') throw new Error('Action 节点当前不能生成首帧') + return submitGeneration(nodeId, 'first_frame', (run, node) => { + if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') + if (node.phase !== 'configuring') throw new Error('动作首帧节点当前不能生成') const input: FirstFrameGenerationInput = { type: 'first_frame', projectId: run.projectId, @@ -264,48 +326,61 @@ export function createWorkflowController({ }) } - function confirmActionFrame(nodeId: ActionWorkflowNode['id'], selectedFirstFrameUrl: string) { + function confirmActionFrame( + nodeId: ActionFirstFrameWorkflowNode['id'], + selectedFirstFrameUrl: string, + ) { ensureRunning() const imageUrl = nonEmpty(selectedFirstFrameUrl, 'selectedFirstFrameUrl') return persist((run) => updateNode(run, nodeId, (node) => { - if (node.type !== 'action') throw new Error('目标节点不是 Action') - if (node.status !== 'active' || node.phase !== 'selecting_action_frame') { - throw new Error('Action 节点当前不能确认首帧') + if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') + if (node.status !== 'active' || node.phase !== 'selecting') { + throw new Error('动作首帧节点当前不能确认首帧') } - return replaceNode(run, { ...node, selectedFirstFrameUrl: imageUrl }) + return unlockReadyNodes( + replaceNode(run, { + ...node, + selectedFirstFrameUrl: imageUrl, + status: 'passed', + phase: 'completed', + }), + ) }), ) } - function generateAnimation(nodeId: ActionWorkflowNode['id'], options: GenerateActionOptions) { + function generateAnimation( + nodeId: ActionFullFrameWorkflowNode['id'], + options: GenerateActionOptions, + ) { const characterId = nonEmpty(options.characterId, 'characterId') - return submitGeneration(nodeId, 'animation', (run, node) => { - if (node.type !== 'action') throw new Error('目标节点不是 Action') - if (node.phase !== 'selecting_action_frame' || !node.selectedFirstFrameUrl) { - throw new Error('Action 节点尚未确认首帧') - } + return submitGeneration(nodeId, 'complete_animation', (run, node) => { + if (node.type !== 'action-full-frame') throw new Error('目标节点不是完整动画') + if (node.phase !== 'ready') throw new Error('完整动画节点当前不能生成') + const firstFrameNode = findSingleDependencyNode(run, node, 'action-first-frame') + if (!firstFrameNode.selectedFirstFrameUrl) throw new Error('动作首帧尚未确认') const input: CompleteAnimationGenerationInput = { type: 'complete_animation', projectId: run.projectId, characterId, - outfitId: node.input.outfitId, - actionType: node.input.type, - firstFrameUrl: node.selectedFirstFrameUrl, - prompt: node.input.prompt, + outfitId: firstFrameNode.input.outfitId, + actionType: firstFrameNode.input.type, + firstFrameUrl: firstFrameNode.selectedFirstFrameUrl, + prompt: firstFrameNode.input.prompt, referenceMedia: options.referenceMedia, } return input }) } - function approveAction(nodeId: ActionWorkflowNode['id']) { + function approveAction(nodeId: ReviewWorkflowNode['id']) { ensureRunning() return persist((run) => updateNode(run, nodeId, (node) => { - if (node.type !== 'action') throw new Error('目标节点不是 Action') - if (node.status !== 'active' || node.phase !== 'reviewing_animation') { - throw new Error('Action 节点当前不能通过审核') + if (node.type !== 'review') throw new Error('目标节点不是动作审核') + if (node.status !== 'active' || node.phase !== 'reviewing') { + throw new Error('动作审核节点当前不能通过') } return unlockReadyNodes( replaceNode(run, { ...node, status: 'passed', phase: 'completed', error: null }), @@ -389,12 +464,7 @@ export function createWorkflowController({ const latestNode = findNode(latest, nodeId) if (latestNode.generations.some((item) => item.role === role)) return latest assertNodeCanRun(latest, latestNode) - return replaceNode(latest, { - ...latestNode, - phase: phaseForRunningRole(role), - generations: [...latestNode.generations, { taskId: generation.id, role }], - error: null, - }) + return replaceNode(latest, attachGenerationReference(latestNode, generation.id, role)) }) const attachedReference = findNode(attached, nodeId).generations.find( (item) => item.role === role, @@ -491,9 +561,8 @@ export function createWorkflowController({ const node = findNode(run, nodeId) const reference = node.generations.find((item) => item.taskId === taskId) if (!reference || node.status !== 'active') return run - // 一个 Action 会先后保留首帧和动画任务引用;只允许当前 phase 对应的任务推进。 - // 这样刷新恢复不会让已经完成的首帧任务把动画阶段倒退回首帧选择。 - if (node.phase !== phaseForRunningRole(reference.role)) return run + // 每种生成任务只属于一种节点;旧任务不能推进另一张卡片。 + if (node.phase !== 'generating' || generationRoleForNode(node) !== reference.role) return run if (generation.status === 'pending' || generation.status === 'running') return run if (generation.status === 'failed') { return replaceNode(run, { @@ -512,32 +581,32 @@ export function createWorkflowController({ reference: WorkflowGenerationRef, generation: Generation, ): WorkflowRun { - if (reference.role === 'character_candidates') { + if (reference.role === 'character_template') { if ( - node.type !== 'character' || + node.type !== 'character-template' || generation.type !== 'character_template' || generation.result?.type !== 'character_template' || generation.result.images.length === 0 ) { return failNode(run, node, '角色候选图结果格式无效') } - return replaceNode(run, { ...node, phase: 'selecting_character', error: null }) + return replaceNode(run, { ...node, phase: 'selecting', error: null }) } - if (reference.role === 'action_frame_candidates') { + if (reference.role === 'first_frame') { if ( - node.type !== 'action' || + node.type !== 'action-first-frame' || generation.type !== 'first_frame' || generation.result?.type !== 'first_frame' || !generation.result.image.url ) { return failNode(run, node, '动作首帧结果格式无效') } - return replaceNode(run, { ...node, phase: 'selecting_action_frame', error: null }) + return replaceNode(run, { ...node, phase: 'selecting', error: null }) } if ( - node.type !== 'action' || + node.type !== 'action-full-frame' || generation.type !== 'complete_animation' || generation.result?.type !== 'complete_animation' ) { @@ -550,7 +619,9 @@ export function createWorkflowController({ `完整动画应为 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际为 ${generation.result.frames.length} 帧`, ) } - return replaceNode(run, { ...node, phase: 'reviewing_animation', error: null }) + return unlockReadyNodes( + replaceNode(run, { ...node, status: 'passed', phase: 'completed', error: null }), + ) } async function resume(): Promise { @@ -561,7 +632,8 @@ export function createWorkflowController({ const run = requireWorkflow() const tasks = run.nodes.flatMap((node) => { if (node.status !== 'active' || !isGeneratingPhase(node)) return [] - const role = roleForRunningPhase(node.phase) + const role = generationRoleForNode(node) + if (!role) return [] const reference = node.generations.find((item) => item.role === role) return reference ? [{ nodeId: node.id, taskId: reference.taskId }] : [] }) @@ -588,6 +660,7 @@ export function createWorkflowController({ }) for (const affectedId of affectedIds) { nodeEpochs.set(affectedId, nodeEpoch(affectedId) + 1) + characterCommands.delete(affectedId) for (const [key] of submissions) { if (key.startsWith(`${affectedId}:`)) submissions.delete(key) } @@ -714,25 +787,66 @@ function assertNodeCanRun(run: WorkflowRun, node: WorkflowNode) { } } -function phaseForRunningRole(role: WorkflowGenerationRole): WorkflowNode['phase'] { - if (role === 'character_candidates') return 'generating_character_candidates' - if (role === 'action_frame_candidates') return 'generating_action_candidates' - return 'generating_animation' +function generationRoleForNode(node: WorkflowNode): WorkflowGenerationRole | null { + if (node.type === 'character-template') return 'character_template' + if (node.type === 'action-first-frame') return 'first_frame' + if (node.type === 'action-full-frame') return 'complete_animation' + return null } -function roleForRunningPhase(phase: WorkflowNode['phase']): WorkflowGenerationRole { - if (phase === 'generating_character_candidates') return 'character_candidates' - if (phase === 'generating_action_candidates') return 'action_frame_candidates' - if (phase === 'generating_animation') return 'animation' - throw new Error(`当前 phase 不是生成阶段:${phase}`) +function assertGenerationRoleMatchesNode(node: WorkflowNode, role: WorkflowGenerationRole) { + if (generationRoleForNode(node) !== role) { + throw new Error(`生成任务 ${role} 不能绑定到 ${node.type} 节点`) + } } -function isGeneratingPhase(node: WorkflowNode) { - return ( - node.phase === 'generating_character_candidates' || - node.phase === 'generating_action_candidates' || - node.phase === 'generating_animation' +function attachGenerationReference( + node: WorkflowNode, + taskId: Generation['id'], + role: WorkflowGenerationRole, +): WorkflowNode { + assertGenerationRoleMatchesNode(node, role) + const update = { + phase: 'generating' as const, + generations: [...node.generations, { taskId, role }], + error: null, + } + if (node.type === 'character-template') return { ...node, ...update } + if (node.type === 'action-first-frame') return { ...node, ...update } + if (node.type === 'action-full-frame') return { ...node, ...update } + throw new Error(`${node.type} 节点不能绑定生成任务`) +} + +function findSingleDependencyNode( + run: WorkflowRun, + node: WorkflowNode, + type: TType, +): Extract { + const matches = node.dependsOnNodeIds + .map((dependencyId) => findNode(run, dependencyId)) + .filter( + (dependency): dependency is Extract => + dependency.type === type, + ) + if (matches.length !== 1) throw new Error(`${node.type} 节点必须且只能依赖一个 ${type} 节点`) + return matches[0] +} + +function findSingleDependentNode( + run: WorkflowRun, + dependencyId: WorkflowNode['id'], + type: TType, +): Extract { + const matches = run.nodes.filter( + (node): node is Extract => + node.type === type && node.dependsOnNodeIds.includes(dependencyId), ) + if (matches.length !== 1) throw new Error(`${dependencyId} 必须且只能连接一个 ${type} 节点`) + return matches[0] +} + +function isGeneratingPhase(node: WorkflowNode) { + return node.phase === 'generating' && generationRoleForNode(node) !== null } function collectDescendantIds(nodes: readonly WorkflowNode[], rootId: string) { @@ -752,24 +866,39 @@ function collectDescendantIds(nodes: readonly WorkflowNode[], rootId: string) { } function resetNode(node: WorkflowNode): WorkflowNode { - if (node.type === 'character') { + if (node.type === 'character-setup') { return { ...node, status: 'locked', - phase: 'configuring_character', + phase: 'configuring', + generations: [], + error: null, + } + } + if (node.type === 'character-template') { + return { + ...node, + status: 'locked', + phase: 'ready', generations: [], error: null, selectedImageUrl: null, } } - return { - ...node, - status: 'locked', - phase: 'configuring_action', - generations: [], - error: null, - selectedFirstFrameUrl: null, + if (node.type === 'action-first-frame') { + return { + ...node, + status: 'locked', + phase: 'configuring', + generations: [], + error: null, + selectedFirstFrameUrl: null, + } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'locked', phase: 'ready', generations: [], error: null } } + return { ...node, status: 'locked', phase: 'reviewing', generations: [], error: null } } function subscriptionKey(nodeId: string, taskId: string) { From 6c4771537c549abcf984cdf0a19d8806bf9691b9 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:29:05 +0800 Subject: [PATCH 3/9] feat(workflow): add action generation method node --- frontend/src/entities/index.ts | 2 + frontend/src/entities/workflow-run/README.md | 5 +- .../src/entities/workflow-run/api.test.ts | 33 ++++++-- frontend/src/entities/workflow-run/api.ts | 14 ++++ .../src/entities/workflow-run/constants.ts | 3 +- frontend/src/entities/workflow-run/index.ts | 11 +++ .../features/workflow-controller/README.md | 3 +- .../workflow-controller/controller.test.ts | 77 +++++++++++++++++-- .../workflow-controller/controller.ts | 65 +++++++++++++++- 9 files changed, 194 insertions(+), 19 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 0a256f46..8e7c3c79 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -58,6 +58,8 @@ export { workflowRunApis } from './workflow-run' export type { ActionFirstFrameWorkflowNode, ActionFullFrameWorkflowNode, + ActionGenerationMethod, + ActionGenerationMethodWorkflowNode, CharacterSetupWorkflowNode, CharacterTemplateWorkflowNode, CreateWorkflowRunInput, diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 40e7b31d..844290d9 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -6,11 +6,12 @@ - 前后端统一使用 `WorkflowNode`。原先前端的 Step 与后端的 Node 是同一概念,已经合并。 - `WorkflowRun.nodes` 直接保存真实节点,不再使用 `root.steps` 或人为包装的根节点。 -- 五类节点与 Workflow Editor 的五类卡片一一对应:角色设定、角色母版、动作首帧、完整动画和审核。 +- 六类节点与 Workflow Editor 的六类卡片一一对应:角色设定、角色母版、动作首帧、资产生成方式、完整动画和审核。 - “提交中、生成中、选择中”仍是节点内部 phase,不再拆成 Step 或额外任务节点。 - 节点通过 `dependsOnNodeIds` 保存直接前置依赖,因此边会与节点一起落库,不再依赖数组顺序猜测连线。 -- 每个 Action 使用 `action-first-frame -> action-full-frame -> review` 三节点链;多条链共同依赖 +- 每个 Action 使用 `action-first-frame -> action-generation-method -> action-full-frame -> review` 四节点链;多条链共同依赖 `character-template`,角色母版通过后即可并行,不互相阻塞。 +- 资产生成方式当前可选 `video-cropping` 与 `3d-to-2d`。3D 转 2D 后端接口未提供前只保存选择并明确阻止提交,不伪装成视频路线。 - Quick Start 与 Workflow Editor 是两种独立界面,但推进同一张节点图,核心数据不区分 `ai/manual driver`。 - 后端不提供 Revision 历史。重做时覆盖旧结果,并用 `nodeId + taskId` 防止旧请求串线。 diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index 21559510..b34b0eef 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -33,12 +33,22 @@ const nodes: WorkflowNode[] = [ input: { outfitId: 'outfit-1', name: '行走', type: 'walk', prompt: null, fps: 12 }, selectedFirstFrameUrl: 'https://cdn.windup.test/walk-first.png', }, + { + id: 'walk-generation-method', + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['walk-first-frame'], + generations: [], + error: null, + method: 'video-cropping', + }, { id: 'walk-full-frame', type: 'action-full-frame', status: 'active', phase: 'generating', - dependsOnNodeIds: ['walk-first-frame'], + dependsOnNodeIds: ['walk-generation-method'], generations: [{ taskId: '93', role: 'complete_animation' }], error: null, }, @@ -80,8 +90,8 @@ function jsonResponse(data: unknown) { } describe('workflowRunApis', () => { - it('hydrates the five visible workflow nodes with explicit dependency edges', async () => { - const fiveNodeDto = { + it('hydrates the six visible workflow nodes with explicit dependency edges', async () => { + const sixNodeDto = { ...workflowRunDto, nodes: [ { @@ -115,12 +125,22 @@ describe('workflowRunApis', () => { input: { outfitId: 'outfit-1', name: 'walk', type: 'walk', prompt: null, fps: 12 }, selectedFirstFrameUrl: 'https://img/walk-first.png', }, + { + id: 'generation-method-1', + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['first-frame-1'], + generations: [], + error: null, + method: 'video-cropping', + }, { id: 'full-frame-1', type: 'action-full-frame', status: 'passed', phase: 'completed', - dependsOnNodeIds: ['first-frame-1'], + dependsOnNodeIds: ['generation-method-1'], generations: [{ taskId: 'task-animation', role: 'complete_animation' }], error: null, }, @@ -135,14 +155,15 @@ describe('workflowRunApis', () => { }, ], } - const apis = await loadWorkflowRunApis(async () => jsonResponse(fiveNodeDto)) + const apis = await loadWorkflowRunApis(async () => jsonResponse(sixNodeDto)) await expect(apis.get('17')).resolves.toMatchObject({ nodes: [ { type: 'character-setup', dependsOnNodeIds: [] }, { type: 'character-template', dependsOnNodeIds: ['setup-1'] }, { type: 'action-first-frame', dependsOnNodeIds: ['template-1'] }, - { type: 'action-full-frame', dependsOnNodeIds: ['first-frame-1'] }, + { type: 'action-generation-method', dependsOnNodeIds: ['first-frame-1'] }, + { type: 'action-full-frame', dependsOnNodeIds: ['generation-method-1'] }, { type: 'review', dependsOnNodeIds: ['full-frame-1'] }, ], }) diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index b9e009b7..54e320bd 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -2,6 +2,7 @@ import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' import type { ActionFirstFrameWorkflowNode, ActionFullFrameWorkflowNode, + ActionGenerationMethodWorkflowNode, CharacterSetupWorkflowNode, CharacterTemplateWorkflowNode, ReviewWorkflowNode, @@ -152,6 +153,18 @@ function isActionFullFrameNode(value: unknown): value is ActionFullFrameWorkflow ) } +function isActionGenerationMethodNode(value: unknown): value is ActionGenerationMethodWorkflowNode { + return ( + isRecord(value) && + value.type === 'action-generation-method' && + hasValidCommonNodeFields(value) && + ['selecting', 'completed'].includes(String(value.phase)) && + hasOnlyGenerationRole(value, null) && + (value.method === null || value.method === 'video-cropping' || value.method === '3d-to-2d') && + (value.phase !== 'completed' || value.method !== null) + ) +} + function isReviewNode(value: unknown): value is ReviewWorkflowNode { return ( isRecord(value) && @@ -167,6 +180,7 @@ function isWorkflowNode(value: unknown): value is WorkflowNode { isCharacterSetupNode(value) || isCharacterTemplateNode(value) || isActionFirstFrameNode(value) || + isActionGenerationMethodNode(value) || isActionFullFrameNode(value) || isReviewNode(value) ) diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts index f7a20379..51d4ed25 100644 --- a/frontend/src/entities/workflow-run/constants.ts +++ b/frontend/src/entities/workflow-run/constants.ts @@ -3,11 +3,12 @@ /** 后端资源状态只表达是否被软删除,不等同于前端节点状态。 */ export const WORKFLOW_RUN_STORAGE_STATUSES = ['active', 'soft_deleted'] as const -/** WorkflowNode 与 Workflow Editor 中用户看到的五类卡片一一对应。 */ +/** WorkflowNode 与 Workflow Editor 中用户看到的六类卡片一一对应。 */ export const WORKFLOW_NODE_TYPES = [ 'character-setup', 'character-template', 'action-first-frame', + 'action-generation-method', 'action-full-frame', 'review', ] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index b8a5d6b3..be0d08f0 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -15,6 +15,9 @@ export type WorkflowNodeStatus = (typeof WORKFLOW_NODE_STATUSES)[number] export type WorkflowNodePhase = (typeof WORKFLOW_NODE_PHASES)[number] export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] +/** 动作资产的生产路线;3D 转 2D 接口尚未提供,但选择必须随 WorkflowRun 落库。 */ +export type ActionGenerationMethod = 'video-cropping' | '3d-to-2d' + /** 一个节点对后端 GenerationTask 的引用;节点可关联零个、一个或多个任务。 */ export interface WorkflowGenerationRef { taskId: Generation['id'] @@ -70,6 +73,13 @@ export interface ActionFirstFrameWorkflowNode extends WorkflowNodeBase { selectedFirstFrameUrl: string | null } +/** 首帧确认后选择完整动画的生产路线。 */ +export interface ActionGenerationMethodWorkflowNode extends WorkflowNodeBase { + type: 'action-generation-method' + phase: 'selecting' | 'completed' + method: ActionGenerationMethod | null +} + /** 基于已确认首帧生成完整动画。 */ export interface ActionFullFrameWorkflowNode extends WorkflowNodeBase { type: 'action-full-frame' @@ -87,6 +97,7 @@ export type WorkflowNode = | CharacterSetupWorkflowNode | CharacterTemplateWorkflowNode | ActionFirstFrameWorkflowNode + | ActionGenerationMethodWorkflowNode | ActionFullFrameWorkflowNode | ReviewWorkflowNode diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index 02226834..f82bc741 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -15,7 +15,8 @@ - `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 - Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。 -- 新增 Action 一次创建首帧、完整动画和审核三节点,不会遗漏首帧或用数组位置猜关系。 +- 新增 Action 一次创建首帧、资产生成方式、完整动画和审核四节点,不会遗漏路线选择或用数组位置猜关系。 +- 当前视频裁剪路线继续调用既有 Generation;3D 转 2D 选择会随 WorkflowRun 落库,但接口提供前明确阻止生成。 - Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 - WorkflowRun 只有在后端 `update` 成功后才替换内存快照,保存失败不会向页面假报成功。 - Generation 已创建但任务引用暂时保存失败时,本实例会保留待附加记录;重试同一命令或 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index bead32bc..5de2b933 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { ActionFirstFrameWorkflowNode, ActionFullFrameWorkflowNode, + ActionGenerationMethodWorkflowNode, CharacterSetupWorkflowNode, CharacterTemplateWorkflowNode, Generation, @@ -84,9 +85,25 @@ function fullFrameNode( type: 'action-full-frame', status: 'locked', phase: 'ready', + dependsOnNodeIds: ['action-walk:generation-method'], + generations: [], + error: null, + ...overrides, + } +} + +function generationMethodNode( + overrides: Partial = {}, +): ActionGenerationMethodWorkflowNode { + return { + id: 'action-walk:generation-method', + type: 'action-generation-method', + status: 'locked', + phase: 'selecting', dependsOnNodeIds: ['action-walk'], generations: [], error: null, + method: null, ...overrides, } } @@ -120,7 +137,7 @@ function completedCharacterNodes(): WorkflowNode[] { } function actionNodes(): WorkflowNode[] { - return [firstFrameNode(), fullFrameNode(), reviewNode()] + return [firstFrameNode(), generationMethodNode(), fullFrameNode(), reviewNode()] } function createRun(nodes: WorkflowNode[] = characterNodes()): WorkflowRun { @@ -253,7 +270,7 @@ describe('WorkflowController', () => { ).rejects.toThrow('已经绑定') }) - it('adds a complete first-frame, full-frame, and review chain for one Action', async () => { + it('adds a complete first-frame, method, full-frame, and review chain for one Action', async () => { const { controller } = createController(createRun(completedCharacterNodes())) const next = await controller.addAction({ nodeId: 'action-walk', input: actionInput() }) @@ -265,11 +282,18 @@ describe('WorkflowController', () => { status: 'active', dependsOnNodeIds: ['template-1'], }, + { + id: 'action-walk:generation-method', + type: 'action-generation-method', + status: 'locked', + dependsOnNodeIds: ['action-walk'], + method: null, + }, { id: 'action-walk:full-frame', type: 'action-full-frame', status: 'locked', - dependsOnNodeIds: ['action-walk'], + dependsOnNodeIds: ['action-walk:generation-method'], }, { id: 'action-walk:review', @@ -280,6 +304,31 @@ describe('WorkflowController', () => { ]) }) + it('保存 3D 转 2D 选择,但接口提供前不误走视频生成', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'https://img/first.png', + }), + generationMethodNode({ status: 'active' }), + fullFrameNode(), + reviewNode(), + ]) + const { controller, generation } = createController(run) + + await controller.selectActionGenerationMethod('action-walk:generation-method', '3d-to-2d') + + await expect( + controller.generateAnimation('action-walk:full-frame', { + characterId: 'character-backend-1', + referenceMedia: [], + }), + ).rejects.toThrow('3D 转 2D 接口尚未提供') + expect(generation.apis.create).not.toHaveBeenCalled() + }) + it('角色母版通过后按显式边同时解锁多个 Action 首帧节点', async () => { const run = createRun([ setupNode({ status: 'passed', phase: 'completed' }), @@ -699,6 +748,11 @@ describe('WorkflowController', () => { phase: 'completed', selectedFirstFrameUrl: 'https://img/first.png', }), + generationMethodNode({ + status: 'passed', + phase: 'completed', + method: 'video-cropping', + }), fullFrameNode({ status: 'active', phase: 'generating', @@ -734,13 +788,13 @@ describe('WorkflowController', () => { ) await controller.approveAction('action-walk:review') - expect(controller.getWorkflow().nodes[4]).toMatchObject({ + expect(controller.getWorkflow().nodes[5]).toMatchObject({ status: 'passed', phase: 'completed', }) }) - it('一个 Action 依次使用独立的首帧、完整动画和审核节点', async () => { + it('一个 Action 依次使用独立的首帧、生成方式、完整动画和审核节点', async () => { const run = createRun([...completedCharacterNodes(), ...actionNodes()]) const { controller, generation } = createController(run) @@ -757,6 +811,7 @@ describe('WorkflowController', () => { }) await flushAsyncWork() await controller.confirmActionFrame('action-walk', 'https://img/first.png') + await controller.selectActionGenerationMethod('action-walk:generation-method', 'video-cropping') await controller.generateAnimation('action-walk:full-frame', { characterId: 'character-backend-1', @@ -787,6 +842,11 @@ describe('WorkflowController', () => { status: 'passed', generations: [{ taskId: 'task-1', role: 'first_frame' }], }, + { + type: 'action-generation-method', + status: 'passed', + method: 'video-cropping', + }, { type: 'action-full-frame', status: 'passed', @@ -805,6 +865,11 @@ describe('WorkflowController', () => { generations: [{ taskId: 'task-first-frame', role: 'first_frame' }], selectedFirstFrameUrl: 'https://img/first.png', }), + generationMethodNode({ + status: 'passed', + phase: 'completed', + method: 'video-cropping', + }), fullFrameNode({ status: 'active', phase: 'generating', @@ -834,6 +899,6 @@ describe('WorkflowController', () => { expect(generation.apis.get).toHaveBeenCalledTimes(1) expect(generation.apis.get).toHaveBeenCalledWith('1', 'task-animation') - expect(controller.getWorkflow().nodes[3].phase).toBe('generating') + expect(controller.getWorkflow().nodes[4].phase).toBe('generating') }) }) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 87052e06..1230cf0a 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,6 +1,8 @@ import type { ActionFirstFrameWorkflowNode, ActionFullFrameWorkflowNode, + ActionGenerationMethod, + ActionGenerationMethodWorkflowNode, CharacterTemplateGenerationInput, CharacterSetupWorkflowNode, CharacterTemplateWorkflowNode, @@ -84,6 +86,10 @@ export interface WorkflowController { nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, ): Promise + selectActionGenerationMethod( + nodeId: ActionGenerationMethodWorkflowNode['id'], + method: ActionGenerationMethod, + ): Promise generateAnimation( nodeId: ActionFullFrameWorkflowNode['id'], options: GenerateActionOptions, @@ -187,9 +193,10 @@ export function createWorkflowController({ function addAction({ nodeId = createId(), dependsOnNodeIds, input }: AddActionInput) { ensureRunning() return persist((run) => { + const methodId = `${nodeId}:generation-method` const fullFrameId = `${nodeId}:full-frame` const reviewId = `${nodeId}:review` - const newIds = [nodeId, fullFrameId, reviewId] + const newIds = [nodeId, methodId, fullFrameId, reviewId] const duplicateId = newIds.find((id) => run.nodes.some((node) => node.id === id)) if (duplicateId) throw new Error(`WorkflowNode 已存在:${duplicateId}`) const dependencies = dependsOnNodeIds @@ -213,9 +220,19 @@ export function createWorkflowController({ type: 'action-full-frame', status: 'locked', phase: 'ready', + dependsOnNodeIds: [methodId], + generations: [], + error: null, + } + const methodNode: ActionGenerationMethodWorkflowNode = { + id: methodId, + type: 'action-generation-method', + status: 'locked', + phase: 'selecting', dependsOnNodeIds: [firstFrameNode.id], generations: [], error: null, + method: null, } const reviewNode: ReviewWorkflowNode = { id: reviewId, @@ -226,7 +243,10 @@ export function createWorkflowController({ generations: [], error: null, } - return { ...run, nodes: [...run.nodes, firstFrameNode, fullFrameNode, reviewNode] } + return { + ...run, + nodes: [...run.nodes, firstFrameNode, methodNode, fullFrameNode, reviewNode], + } }) } @@ -350,6 +370,29 @@ export function createWorkflowController({ ) } + function selectActionGenerationMethod( + nodeId: ActionGenerationMethodWorkflowNode['id'], + method: ActionGenerationMethod, + ) { + ensureRunning() + if (method !== 'video-cropping' && method !== '3d-to-2d') { + return Promise.reject(new Error(`不支持的资产生成方式:${String(method)}`)) + } + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'action-generation-method') { + throw new Error('目标节点不是资产生成方式') + } + if (node.status !== 'active' || node.phase !== 'selecting') { + throw new Error('资产生成方式节点当前不能选择') + } + return unlockReadyNodes( + replaceNode(run, { ...node, method, status: 'passed', phase: 'completed' }), + ) + }), + ) + } + function generateAnimation( nodeId: ActionFullFrameWorkflowNode['id'], options: GenerateActionOptions, @@ -358,7 +401,12 @@ export function createWorkflowController({ return submitGeneration(nodeId, 'complete_animation', (run, node) => { if (node.type !== 'action-full-frame') throw new Error('目标节点不是完整动画') if (node.phase !== 'ready') throw new Error('完整动画节点当前不能生成') - const firstFrameNode = findSingleDependencyNode(run, node, 'action-first-frame') + const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') + if (!methodNode.method) throw new Error('尚未选择资产生成方式') + if (methodNode.method === '3d-to-2d') { + throw new Error('3D 转 2D 接口尚未提供,暂时不能开始生成') + } + const firstFrameNode = findSingleDependencyNode(run, methodNode, 'action-first-frame') if (!firstFrameNode.selectedFirstFrameUrl) throw new Error('动作首帧尚未确认') const input: CompleteAnimationGenerationInput = { type: 'complete_animation', @@ -713,6 +761,7 @@ export function createWorkflowController({ confirmCharacter, generateActionFrame, confirmActionFrame, + selectActionGenerationMethod, generateAnimation, approveAction, resume, @@ -895,6 +944,16 @@ function resetNode(node: WorkflowNode): WorkflowNode { selectedFirstFrameUrl: null, } } + if (node.type === 'action-generation-method') { + return { + ...node, + status: 'locked', + phase: 'selecting', + method: null, + generations: [], + error: null, + } + } if (node.type === 'action-full-frame') { return { ...node, status: 'locked', phase: 'ready', generations: [], error: null } } From 98be03e896126b98d305dfb42f919b68d99740ee Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:34:24 +0800 Subject: [PATCH 4/9] feat(workflow-editor): show six-node asset route choice --- .../src/pages/workflow-editor/index.test.tsx | 39 +++++++++ frontend/src/pages/workflow-editor/index.tsx | 87 +++++++++++++++++-- 2 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/workflow-editor/index.test.tsx diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx new file mode 100644 index 00000000..bb752c0d --- /dev/null +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -0,0 +1,39 @@ +/** @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 { WorkflowEditorPage } from './index' + +afterEach(cleanup) + +describe('WorkflowEditorPage', () => { + it('按六节点顺序展示资产生成方式选择', () => { + const onSelect = vi.fn() + render( + + + } + /> + + , + ) + + 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']]) + }) + + it('没有 Controller 装配时不在页面内伪造选择', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: /视频裁剪/ })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: /3D 转 2D/ })).toHaveProperty('disabled', true) + }) +}) diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index 9c5afec9..ef527e41 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -1,13 +1,90 @@ +import { useParams } from 'react-router' + +import type { ActionGenerationMethod } from '@/entities' import { PageContainer } from '@/shared/ui' -/** 工作流画布。 */ -export function WorkflowEditorPage() { +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 + +export interface WorkflowEditorPageProps { + selectedGenerationMethod?: ActionGenerationMethod | null + onSelectGenerationMethod?: (method: ActionGenerationMethod) => void +} + +/** 工作流画布骨架;真实状态只来自 WorkflowRun,不在页面内伪造节点推进。 */ +export function WorkflowEditorPage({ + selectedGenerationMethod = null, + onSelectGenerationMethod, +}: WorkflowEditorPageProps = {}) { + const { runId } = useParams<{ runId: string }>() return ( -
-

工作流画布

-

本次只提交模块划分与接口,页面实现进后续 PR。

+
+
+

WORKFLOW EDITOR

+

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

+

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

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

    {card.title}

    +

    {card.detail}

    + {card.index === '04' ? ( + + ) : null} +
  2. + ))} +
) } + +function GenerationMethodChoice({ + selected, + onSelect, +}: { + selected: ActionGenerationMethod | null + onSelect?: (method: ActionGenerationMethod) => void +}) { + return ( +
+ + +
+ ) +} From aa75e234ee52aaa1929342e8053d735c03ad4b96 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:34:43 +0800 Subject: [PATCH 5/9] refactor(quick-start): align with six-node controller --- frontend/src/pages/quick-start/README.md | 16 + frontend/src/pages/quick-start/index.test.tsx | 111 +++++++ frontend/src/pages/quick-start/index.tsx | 308 +++++++++++++++++- frontend/src/pages/quick-start/service.ts | 62 ++++ 4 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/quick-start/README.md create mode 100644 frontend/src/pages/quick-start/index.test.tsx create mode 100644 frontend/src/pages/quick-start/service.ts diff --git a/frontend/src/pages/quick-start/README.md b/frontend/src/pages/quick-start/README.md new file mode 100644 index 00000000..8b74a434 --- /dev/null +++ b/frontend/src/pages/quick-start/README.md @@ -0,0 +1,16 @@ +# Quick Start + +Quick Start 与 Workflow Editor 是两个独立界面,但必须推进同一份 WorkflowRun 节点图。 + +- Workflow Editor 等待用户逐节点操作。 +- Quick Start 隐藏节点细节,由 AI 连续调用同一组 Controller 方法。 +- 页面只消费 WorkflowRun 的只读投影,不建立第二套 Store、Revision、Step 或 driver。 +- 一次角色与其 Action 保持在同一条 Run;Action 使用 `首帧 -> 资产生成方式 -> 完整动画 -> 审核` 四节点链。 +- Quick Start 不向用户增加一次手动选择:AI 当前自动选择可执行的“视频裁剪”路线,并在进度区展示选择结果。3D 转 2D 接口到位后,装配实现可自动改选新路线。 + +本 PR 保留 `QuickStartService` 页面用例接口。真实实现必须由 App 使用 #107 的 +`WorkflowController`、Project、Character、Generation 和发布能力装配。相关能力未配置时页面明确禁用, +不得回退 Mock 或伪造完成结果。 + +动画审核直接播放完整动画 Generation 的逐帧结果。审核通过后的 Character 写入与 Playtest 目标解析 +属于装配用例,不由页面猜测后端字段。 diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx new file mode 100644 index 00000000..349a4819 --- /dev/null +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -0,0 +1,111 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' + +import type { QuickStartService, QuickStartView } from './service' +import { QuickStartPage } from './index' + +afterEach(cleanup) + +function service(overrides: Partial = {}): QuickStartService { + return { + unavailableReason: null, + start: vi.fn(async () => ({ runId: 'run-1' })), + load: vi.fn(async () => null), + subscribe: vi.fn(() => () => undefined), + interrupt: vi.fn(async () => undefined), + approve: vi.fn(async () => ({ characterId: 'character-1', outfitId: 'outfit-1' })), + ...overrides, + } +} + +function view(overrides: Partial = {}): QuickStartView { + return { + runId: 'run-1', + status: 'running', + title: '像素信使', + message: '正在生成完整动画', + completedNodes: 3, + totalNodes: 6, + generationMethod: null, + fps: 12, + animationFrames: [], + ...overrides, + } +} + +function renderPage(testService: QuickStartService, path = '/quick-start') { + return render( + + + + } /> + } /> + + , + ) +} + +function LocationProbe() { + const location = useLocation() + return

{location.pathname}

+} + +describe('QuickStartPage', () => { + it('未装配时明确禁用,不回退 Mock', () => { + renderPage(service({ unavailableReason: '服务尚未配置' })) + expect(screen.getByRole('alert').textContent).toContain('服务尚未配置') + expect(screen.getByRole('button', { name: '开始自动生成' })).toHaveProperty('disabled', true) + }) + + it('提交自然语言后进入同一 WorkflowRun 路由', async () => { + const testService = service() + renderPage(testService) + fireEvent.change(screen.getByLabelText('角色描述'), { target: { value: '像素信使' } }) + fireEvent.change(screen.getByLabelText('动作描述(可选)'), { target: { value: '向前奔跑' } }) + fireEvent.click(screen.getByRole('button', { name: '开始自动生成' })) + + await waitFor(() => expect(screen.getByText('/quick-start/run-1')).toBeTruthy()) + expect(testService.start).toHaveBeenCalledWith({ + prompt: '像素信使', + actionDescription: '向前奔跑', + }) + }) + + it('审核视图播放同一份逐帧结果并进入 Playtest', async () => { + const testService = service({ + load: vi.fn(async () => + view({ + status: 'review', + totalNodes: 6, + completedNodes: 5, + generationMethod: 'video-cropping', + animationFrames: ['1.png', '2.png'], + }), + ), + }) + renderPage(testService, '/quick-start/run-1') + + expect(await screen.findByAltText('动画第 1 帧')).toHaveProperty( + 'src', + expect.stringContaining('1.png'), + ) + expect(screen.getByText('资产路线:视频裁剪(自动选择)')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '审核通过并打开预览台' })) + await waitFor(() => expect(screen.getByText('/playtest/character-1/outfit-1')).toBeTruthy()) + expect(testService.approve).toHaveBeenCalledWith('run-1') + }) + + it('卸载时取消 Controller 投影订阅', async () => { + const unsubscribe = vi.fn() + const testService = service({ + load: vi.fn(async () => view()), + subscribe: vi.fn(() => unsubscribe), + }) + const rendered = renderPage(testService, '/quick-start/run-1') + await screen.findByText('像素信使') + rendered.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 10e1be5b..2e8c04b7 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,13 +1,305 @@ -import { PageContainer } from '@/shared/ui' +import { type FormEvent, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router' + +import type { QuickStartService, QuickStartView } from './service' +import { unavailableQuickStartService } from './service' + +const EXAMPLES = [ + '轻装信使,侧视像素风,轮廓清晰', + '戴护目镜的机械师,明亮街机风格', + '披斗篷的森林法师,动作轻快', +] + +export interface QuickStartPageProps { + service?: QuickStartService +} + +/** Quick Start 是独立 AI 界面;节点推进仍交给与 Workflow Editor 共用的 Controller。 */ +export function QuickStartPage({ service = unavailableQuickStartService }: QuickStartPageProps) { + const { runId } = useParams<{ runId: string }>() + return runId ? ( + + ) : ( + + ) +} + +function QuickStartInput({ service }: { service: QuickStartService }) { + const navigate = useNavigate() + const [prompt, setPrompt] = useState('') + const [actionDescription, setActionDescription] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function submit(event: FormEvent) { + event.preventDefault() + if (submitting || service.unavailableReason || !prompt.trim()) return + setSubmitting(true) + setError(null) + try { + const result = await service.start({ + prompt: prompt.trim(), + actionDescription: actionDescription.trim() || null, + }) + navigate(`/quick-start/${encodeURIComponent(result.runId)}`) + } catch (cause) { + setError(errorMessage(cause, '创建失败,请稍后重试')) + } finally { + setSubmitting(false) + } + } -/** 快速开始。 */ -export function QuickStartPage() { return ( - -
-

快速开始

-

本次只提交模块划分与接口,页面实现进后续 PR。

+
+
+

QUICK START

+

+ 一句话完成角色与动作 +

+

+ AI 自动推进同一条工作流,你只需描述目标并审核最终动画。 +

+
+ +
void submit(event)} className="mt-8 space-y-5"> +