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 01/11] 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 02/11] 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 03/11] 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 04/11] 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 2d907faf6448bb135eb04e25c3dc9b3853c56585 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:42:30 +0800 Subject: [PATCH 05/11] fix(workflow-controller): surface async persistence failures --- .../src/features/workflow-controller/controller.test.ts | 1 + frontend/src/features/workflow-controller/controller.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 5de2b933..1e264af3 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -827,6 +827,7 @@ describe('WorkflowController', () => { type: 'first_frame', characterId: 'character-backend-1', outfitId: 'outfit-1', + referenceMedia: ['https://img/knight.png'], }), ) expect(generation.apis.create).toHaveBeenNthCalledWith( diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 1230cf0a..009025f1 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -333,6 +333,10 @@ export function createWorkflowController({ return submitGeneration(nodeId, 'first_frame', (run, node) => { if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') if (node.phase !== 'configuring') throw new Error('动作首帧节点当前不能生成') + const templateNode = findSingleDependencyNode(run, node, 'character-template') + if (!templateNode.selectedImageUrl) throw new Error('角色母版尚未确认') + // 该 URL 来自已校验的 Generation 结果,符合当前后端 reference_image_urls 契约。 + const characterTemplateReference = templateNode.selectedImageUrl as MediaReference const input: FirstFrameGenerationInput = { type: 'first_frame', projectId: run.projectId, @@ -340,7 +344,7 @@ export function createWorkflowController({ outfitId: node.input.outfitId, actionType: node.input.type, prompt: node.input.prompt, - referenceMedia: options.referenceMedia, + referenceMedia: [...new Set([characterTemplateReference, ...options.referenceMedia])], } return input }) From 9aa10735be5ad96f50e0c4913dc2bc514c78f4ef Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:29:15 +0800 Subject: [PATCH 06/11] feat(workflow): preserve archived action branches --- .../src/entities/workflow-run/api.test.ts | 16 +++++ frontend/src/entities/workflow-run/api.ts | 7 +- frontend/src/entities/workflow-run/index.ts | 5 ++ .../workflow-controller/controller.test.ts | 51 ++++++++++++++ .../workflow-controller/controller.ts | 66 +++++++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index b34b0eef..4a80c927 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -238,6 +238,22 @@ describe('workflowRunApis', () => { }) }) + it('rejects an empty deletion marker instead of treating it as archived history', async () => { + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ + ...workflowRunDto, + nodes: nodes.map((node) => + node.id === 'walk-full-frame' ? { ...node, deletedAt: '' } : node, + ), + }), + ) + + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + it('rejects a dependency that points outside the persisted graph', async () => { const apis = await loadWorkflowRunApis(async () => jsonResponse({ diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index 54e320bd..944d6dc9 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -57,7 +57,12 @@ function hasValidCommonNodeFields(value: Record): boolean { new Set(value.dependsOnNodeIds).size !== value.dependsOnNodeIds.length || !Array.isArray(value.generations) || !value.generations.every(isGenerationRef) || - !isNullableString(value.error) + !isNullableString(value.error) || + !( + value.deletedAt === undefined || + value.deletedAt === null || + (typeof value.deletedAt === 'string' && value.deletedAt.trim().length > 0) + ) ) { return false } diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index be0d08f0..68f72ba7 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -36,6 +36,11 @@ interface WorkflowNodeBase { dependsOnNodeIds: string[] generations: WorkflowGenerationRef[] error: string | null + /** + * 已发布资产被用户删除的时间。节点仍保留生成输入、任务引用与审核历史; + * 旧数据没有该字段时视为未删除。 + */ + deletedAt?: string | null } export interface WorkflowCharacterInput { diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 1e264af3..591fa730 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -225,6 +225,7 @@ function createController(run = createRun()) { workflowRunApis: workflow.apis, generationApis: generation.apis, createId: () => 'action-created', + now: () => '2026-08-09T00:00:00.000Z', onAsyncError: (error) => asyncErrors.push(error), }) return { controller, workflow, generation, asyncErrors } @@ -304,6 +305,56 @@ describe('WorkflowController', () => { ]) }) + it('归档已发布 Action 时只标记对应四节点分支并保留其他节点', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ status: 'passed', phase: 'completed', selectedFirstFrameUrl: 'walk.png' }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ status: 'passed', phase: 'completed' }), + reviewNode({ status: 'passed', phase: 'completed' }), + firstFrameNode({ + id: 'action-jump', + status: 'passed', + phase: 'completed', + input: actionInput({ name: '跳跃', type: 'jump' }), + selectedFirstFrameUrl: 'jump.png', + }), + generationMethodNode({ + id: 'action-jump:generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-jump'], + method: 'video-cropping', + }), + fullFrameNode({ + id: 'action-jump:full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-jump:generation-method'], + }), + reviewNode({ + id: 'action-jump:review', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-jump:full-frame'], + }), + ]) + const { controller, workflow } = createController(run) + + const archived = await controller.archiveAction('action-walk:full-frame') + + expect(archived.nodes.filter((node) => node.deletedAt).map((node) => node.id)).toEqual([ + 'action-walk', + 'action-walk:generation-method', + 'action-walk:full-frame', + 'action-walk:review', + ]) + expect(archived.nodes.find((node) => node.id === 'setup-1')?.deletedAt).toBeUndefined() + expect(archived.nodes.find((node) => node.id === 'template-1')?.deletedAt).toBeUndefined() + expect(archived.nodes.find((node) => node.id === 'action-jump')?.deletedAt).toBeUndefined() + expect(workflow.getSaved()).toEqual(archived) + }) + it('保存 3D 转 2D 选择,但接口提供前不误走视频生成', async () => { const run = createRun([ ...completedCharacterNodes(), diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 009025f1..55b2b004 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -55,6 +55,7 @@ export interface CreateWorkflowControllerOptions { workflowRunApis: WorkflowRunApis generationApis: GenerationApis createId?: () => string + now?: () => string /** SSE 回调无法 await,异步保存错误通过此处交给装配层展示或记录。 */ onAsyncError: (error: Error) => void } @@ -95,6 +96,8 @@ export interface WorkflowController { options: GenerateActionOptions, ): Promise approveAction(nodeId: ReviewWorkflowNode['id']): Promise + /** 已发布 Action 删除后,保留其四节点历史并标记为已删除。 */ + archiveAction(nodeId: ActionFullFrameWorkflowNode['id']): Promise /** 刷新恢复时查询已记录的 Generation,再恢复 SSE。 */ resume(): Promise @@ -127,6 +130,7 @@ export function createWorkflowController({ workflowRunApis, generationApis, createId = createBrowserSafeId, + now = () => new Date().toISOString(), onAsyncError, }: CreateWorkflowControllerOptions): WorkflowController { let current = workflow ? structuredClone(workflow) : null @@ -441,6 +445,31 @@ export function createWorkflowController({ ) } + function archiveAction(nodeId: ActionFullFrameWorkflowNode['id']) { + ensureRunning() + return persist((run) => { + const fullFrameNode = findNode(run, nodeId) + if (fullFrameNode.type !== 'action-full-frame' || fullFrameNode.status !== 'passed') { + throw new Error('只能归档已完成的动作资产') + } + const reviewNodes = run.nodes.filter( + (node) => node.type === 'review' && node.dependsOnNodeIds.includes(fullFrameNode.id), + ) + if (reviewNodes.length === 0 || reviewNodes.some((node) => node.status !== 'passed')) { + throw new Error('动作尚未通过审核,不能标记为已删除') + } + + const branchIds = collectActionBranchIds(run.nodes, fullFrameNode.id) + const deletedAt = now() + return { + ...run, + nodes: run.nodes.map((node) => + branchIds.has(node.id) ? { ...node, deletedAt } : node, + ) as WorkflowNode[], + } + }) + } + function submitGeneration( nodeId: WorkflowNode['id'], role: WorkflowGenerationRole, @@ -768,6 +797,7 @@ export function createWorkflowController({ selectActionGenerationMethod, generateAnimation, approveAction, + archiveAction, resume, interrupt, restartFromNode, @@ -777,6 +807,42 @@ export function createWorkflowController({ } } +function collectActionBranchIds( + nodes: readonly WorkflowNode[], + fullFrameNodeId: ActionFullFrameWorkflowNode['id'], +) { + const branchIds = new Set([fullFrameNodeId]) + const frontier = [fullFrameNodeId] + while (frontier.length > 0) { + const currentId = frontier.shift()! + const current = nodes.find((node) => node.id === currentId) + if (!current) continue + + for (const dependencyId of current.dependsOnNodeIds) { + const dependency = nodes.find((node) => node.id === dependencyId) + if ( + dependency && + dependency.type !== 'character-setup' && + dependency.type !== 'character-template' && + !branchIds.has(dependency.id) + ) { + branchIds.add(dependency.id) + frontier.push(dependency.id) + } + } + for (const dependent of nodes) { + if ( + dependent.type === 'review' && + dependent.dependsOnNodeIds.includes(currentId) && + !branchIds.has(dependent.id) + ) { + branchIds.add(dependent.id) + } + } + } + return branchIds +} + function updateNode( run: WorkflowRun, nodeId: WorkflowNode['id'], From 2b38f6c35c45cb0199b7479600844243a1843bf8 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:32:07 +0800 Subject: [PATCH 07/11] docs(workflow): document archived action history --- frontend/src/entities/workflow-run/README.md | 3 ++- frontend/src/features/workflow-controller/README.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 844290d9..b0a05bc0 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -14,6 +14,7 @@ - 资产生成方式当前可选 `video-cropping` 与 `3d-to-2d`。3D 转 2D 后端接口未提供前只保存选择并明确阻止提交,不伪装成视频路线。 - Quick Start 与 Workflow Editor 是两种独立界面,但推进同一张节点图,核心数据不区分 `ai/manual driver`。 - 后端不提供 Revision 历史。重做时覆盖旧结果,并用 `nodeId + taskId` 防止旧请求串线。 +- 已发布 Action 被删除后,对应四节点分支以 `deletedAt` 标记并继续留在图中,生成输入、任务引用和审核历史不会被擦除;角色母版及其他并行动作不受影响。 ## 前后端边界 @@ -28,4 +29,4 @@ HTTP 接口严格对应 `POST /workflow-runs`、`GET/PATCH/DELETE /workflow-runs - `constants.ts`:核心节点状态、类型和 phase。 - `index.ts`:WorkflowRun、WorkflowNode 与 API 类型。 - `api.ts`:后端 DTO 映射、节点图校验和 HTTP 适配。 -- `api.test.ts`:直接节点映射、边校验及并行 Action 数据测试。 +- `api.test.ts`:直接节点映射、边校验、删除标记及并行 Action 数据测试。 diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index f82bc741..b0ad2d6c 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -19,6 +19,7 @@ - 当前视频裁剪路线继续调用既有 Generation;3D 转 2D 选择会随 WorkflowRun 落库,但接口提供前明确阻止生成。 - Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 - WorkflowRun 只有在后端 `update` 成功后才替换内存快照,保存失败不会向页面假报成功。 +- `archiveAction()` 只标记已经完成并通过审核的 Action 四节点分支;它不删除 Character 资产,也不改动共享角色节点或其他 Action。 - Generation 已创建但任务引用暂时保存失败时,本实例会保留待附加记录;重试同一命令或 `resume()` 会复用原任务,不会再次创建和重复计费。 - 中断只停止前端自动处理和 SSE。当前后端没有取消接口,因此不会伪装成已取消任务; From 9c443f9763d3b51c395617297839049d62935b53 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:44:20 +0800 Subject: [PATCH 08/11] docs(home): remove stale controller placeholder --- frontend/src/pages/home/index.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 4ae11e8a..307613a4 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -80,12 +80,6 @@ export function HomePage() { actionLabel="写下角色设定" tone="dark" /> - {/* - 画布入口必须先确定角色挂在哪个项目下,所以动作条悬停后分成新建与继续两条。 - 新建直达创建页;继续先落项目中心选项目,等项目级 WorkflowRun 历史页面就位后 - 再指到那里。两条都还进不了 /workflow-editor/:runId——建 workflow run 需要 - WorkflowController,它目前只有接口没有实现。 - */} Date: Mon, 10 Aug 2026 01:25:06 +0800 Subject: [PATCH 09/11] fix(workflow-controller): harden node lifecycle --- .../features/workflow-controller/README.md | 5 +- .../workflow-controller/controller.test.ts | 248 ++++++++++++++---- .../workflow-controller/controller.ts | 75 ++++-- .../src/features/workflow-controller/index.ts | 2 +- 4 files changed, 252 insertions(+), 78 deletions(-) diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index b0ad2d6c..98fea11c 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -15,14 +15,15 @@ - `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 - Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。 -- 新增 Action 一次创建首帧、资产生成方式、完整动画和审核四节点,不会遗漏路线选择或用数组位置猜关系。 +- 新增 Action 一次创建动作首帧、动作生成方式、完整动画和审核四个 node,不会遗漏路线选择或用数组位置猜关系。 +- Controller 方法与后端 Generation、WorkflowRun node 使用同一概念名:`characterTemplate`、`firstFrame`、`completeAnimation` 和 `review`,不再为同一概念保留另一套叫法。 - 当前视频裁剪路线继续调用既有 Generation;3D 转 2D 选择会随 WorkflowRun 落库,但接口提供前明确阻止生成。 - Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 - WorkflowRun 只有在后端 `update` 成功后才替换内存快照,保存失败不会向页面假报成功。 - `archiveAction()` 只标记已经完成并通过审核的 Action 四节点分支;它不删除 Character 资产,也不改动共享角色节点或其他 Action。 - Generation 已创建但任务引用暂时保存失败时,本实例会保留待附加记录;重试同一命令或 `resume()` 会复用原任务,不会再次创建和重复计费。 -- 中断只停止前端自动处理和 SSE。当前后端没有取消接口,因此不会伪装成已取消任务; +- 中断只停止前端自动处理和 SSE,可由 `resume()` 继续。当前后端没有取消接口,因此不会伪装成已取消任务; 恢复时先订阅再查询任务快照,既能拿终态,也不会漏掉查询与订阅之间的完成事件。 - Controller 不包含页面、Playtest、后端实现、发布和导出逻辑。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 591fa730..a165f2f6 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -81,11 +81,11 @@ function fullFrameNode( overrides: Partial = {}, ): ActionFullFrameWorkflowNode { return { - id: 'action-walk:full-frame', + id: 'action-walk:action-full-frame', type: 'action-full-frame', status: 'locked', phase: 'ready', - dependsOnNodeIds: ['action-walk:generation-method'], + dependsOnNodeIds: ['action-walk:action-generation-method'], generations: [], error: null, ...overrides, @@ -96,7 +96,7 @@ function generationMethodNode( overrides: Partial = {}, ): ActionGenerationMethodWorkflowNode { return { - id: 'action-walk:generation-method', + id: 'action-walk:action-generation-method', type: 'action-generation-method', status: 'locked', phase: 'selecting', @@ -114,7 +114,7 @@ function reviewNode(overrides: Partial = {}): ReviewWorkflow type: 'review', status: 'locked', phase: 'reviewing', - dependsOnNodeIds: ['action-walk:full-frame'], + dependsOnNodeIds: ['action-walk:action-full-frame'], generations: [], error: null, ...overrides, @@ -284,27 +284,39 @@ describe('WorkflowController', () => { dependsOnNodeIds: ['template-1'], }, { - id: 'action-walk:generation-method', + id: 'action-walk:action-generation-method', type: 'action-generation-method', status: 'locked', dependsOnNodeIds: ['action-walk'], method: null, }, { - id: 'action-walk:full-frame', + id: 'action-walk:action-full-frame', type: 'action-full-frame', status: 'locked', - dependsOnNodeIds: ['action-walk:generation-method'], + dependsOnNodeIds: ['action-walk:action-generation-method'], }, { id: 'action-walk:review', type: 'review', status: 'locked', - dependsOnNodeIds: ['action-walk:full-frame'], + dependsOnNodeIds: ['action-walk:action-full-frame'], }, ]) }) + it('新增 Action 只能依赖一个角色母版节点', async () => { + const { controller } = createController(createRun(completedCharacterNodes())) + + await expect( + controller.addAction({ + nodeId: 'action-invalid', + dependsOnNodeIds: ['setup-1'], + input: actionInput(), + }), + ).rejects.toThrow('必须且只能依赖一个角色母版节点') + }) + it('归档已发布 Action 时只标记对应四节点分支并保留其他节点', async () => { const run = createRun([ ...completedCharacterNodes(), @@ -320,33 +332,33 @@ describe('WorkflowController', () => { selectedFirstFrameUrl: 'jump.png', }), generationMethodNode({ - id: 'action-jump:generation-method', + id: 'action-jump:action-generation-method', status: 'passed', phase: 'completed', dependsOnNodeIds: ['action-jump'], method: 'video-cropping', }), fullFrameNode({ - id: 'action-jump:full-frame', + id: 'action-jump:action-full-frame', status: 'passed', phase: 'completed', - dependsOnNodeIds: ['action-jump:generation-method'], + dependsOnNodeIds: ['action-jump:action-generation-method'], }), reviewNode({ id: 'action-jump:review', status: 'passed', phase: 'completed', - dependsOnNodeIds: ['action-jump:full-frame'], + dependsOnNodeIds: ['action-jump:action-full-frame'], }), ]) const { controller, workflow } = createController(run) - const archived = await controller.archiveAction('action-walk:full-frame') + const archived = await controller.archiveAction('action-walk:action-full-frame') expect(archived.nodes.filter((node) => node.deletedAt).map((node) => node.id)).toEqual([ 'action-walk', - 'action-walk:generation-method', - 'action-walk:full-frame', + 'action-walk:action-generation-method', + 'action-walk:action-full-frame', 'action-walk:review', ]) expect(archived.nodes.find((node) => node.id === 'setup-1')?.deletedAt).toBeUndefined() @@ -369,10 +381,13 @@ describe('WorkflowController', () => { ]) const { controller, generation } = createController(run) - await controller.selectActionGenerationMethod('action-walk:generation-method', '3d-to-2d') + await controller.selectActionGenerationMethod( + 'action-walk:action-generation-method', + '3d-to-2d', + ) await expect( - controller.generateAnimation('action-walk:full-frame', { + controller.generateCompleteAnimation('action-walk:action-full-frame', { characterId: 'character-backend-1', referenceMedia: [], }), @@ -393,7 +408,7 @@ describe('WorkflowController', () => { ]) const { controller } = createController(run) - const next = await controller.confirmCharacter('template-1', 'https://img/knight.png') + const next = await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') expect(next.nodes).toEqual( expect.arrayContaining([ @@ -407,7 +422,7 @@ describe('WorkflowController', () => { it('提交角色设定后在母版节点记录任务并进入候选选择', async () => { const { controller, workflow, generation, asyncErrors } = createController() - await controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) expect(generation.apis.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -454,10 +469,10 @@ describe('WorkflowController', () => { vi.mocked(generation.apis.create).mockRejectedValueOnce(new Error('生成服务暂时不可用')) await expect( - controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }), + controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }), ).rejects.toThrow('生成服务暂时不可用') await expect( - controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }), + controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }), ).resolves.toMatchObject({ nodes: expect.arrayContaining([ expect.objectContaining({ @@ -474,8 +489,8 @@ describe('WorkflowController', () => { const options = { spriteWidth: 64, spriteHeight: 64 } await Promise.all([ - controller.generateCharacter('setup-1', options), - controller.generateCharacter('setup-1', options), + controller.generateCharacterTemplate('setup-1', options), + controller.generateCharacterTemplate('setup-1', options), ]) expect(generation.apis.create).toHaveBeenCalledTimes(1) @@ -522,7 +537,7 @@ describe('WorkflowController', () => { onAsyncError: vi.fn(), }) - await controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) expect(workflow.apis.update).toHaveBeenCalledTimes(3) expect(controller.getWorkflow().nodes[1].phase).toBe('selecting') @@ -530,7 +545,7 @@ describe('WorkflowController', () => { it('中断后忽略迟到结果,恢复时查询终态再推进', async () => { const { controller, generation } = createController() - await controller.generateCharacter('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) await controller.interrupt() generation.emit({ @@ -589,7 +604,7 @@ describe('WorkflowController', () => { }), expect.objectContaining({ id: 'action-walk', status: 'locked', generations: [] }), expect.objectContaining({ - id: 'action-walk:full-frame', + id: 'action-walk:action-full-frame', status: 'locked', generations: [], }), @@ -597,6 +612,75 @@ describe('WorkflowController', () => { ) }) + it('重做一个 Action 只重置它的后代并保留显式边和其他并行 Action', async () => { + const jumpNodes: WorkflowNode[] = [ + firstFrameNode({ + id: 'action-jump', + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-jump', role: 'first_frame' }], + input: actionInput({ name: '跳跃', type: 'jump' }), + }), + generationMethodNode({ + id: 'action-jump:action-generation-method', + dependsOnNodeIds: ['action-jump'], + }), + fullFrameNode({ + id: 'action-jump:action-full-frame', + dependsOnNodeIds: ['action-jump:action-generation-method'], + }), + reviewNode({ + id: 'action-jump:review', + dependsOnNodeIds: ['action-jump:action-full-frame'], + }), + ] + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'walk.png', + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-walk', role: 'complete_animation' }], + }), + reviewNode(), + ...jumpNodes, + ]) + const { controller } = createController(run) + + const restarted = await controller.restartFromNode('action-walk:action-generation-method') + + expect(restarted.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'action-walk:action-generation-method', + status: 'active', + dependsOnNodeIds: ['action-walk'], + }), + expect.objectContaining({ + id: 'action-walk:action-full-frame', + status: 'locked', + dependsOnNodeIds: ['action-walk:action-generation-method'], + }), + expect.objectContaining({ + id: 'action-walk:review', + status: 'locked', + dependsOnNodeIds: ['action-walk:action-full-frame'], + }), + expect.objectContaining({ + id: 'action-jump', + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-jump', role: 'first_frame' }], + }), + ]), + ) + }) + it('生成请求尚未返回时重做,旧任务不能挂回新执行线', async () => { const run = createRun([...completedCharacterNodes(), ...actionNodes()]) const workflow = createWorkflowApis(run) @@ -623,14 +707,14 @@ describe('WorkflowController', () => { onAsyncError: vi.fn(), }) - const oldSubmission = controller.generateActionFrame('action-walk', { + const oldSubmission = controller.generateFirstFrame('action-walk', { characterId: 'character-1', referenceMedia: [], }) await Promise.resolve() await controller.restartFromNode('action-walk') - const newSubmission = controller.generateActionFrame('action-walk', { + const newSubmission = controller.generateFirstFrame('action-walk', { characterId: 'character-1', referenceMedia: [], }) @@ -646,7 +730,7 @@ describe('WorkflowController', () => { error: null, }) await oldSubmission - const sameNewSubmission = controller.generateActionFrame('action-walk', { + const sameNewSubmission = controller.generateFirstFrame('action-walk', { characterId: 'character-1', referenceMedia: [], }) @@ -667,7 +751,10 @@ describe('WorkflowController', () => { ]) }) - it('角色母版请求尚未返回时从设定重做,新提交不会复用旧命令', async () => { + it.each([ + ['角色设定', 'setup-1'], + ['角色母版', 'template-1'], + ])('角色母版请求尚未返回时从%s node 重做,新提交不会复用旧命令', async (_label, nodeId) => { const run = createRun() const workflow = createWorkflowApis(run) const pendingResolvers: Array<(generation: Generation) => void> = [] @@ -693,19 +780,20 @@ describe('WorkflowController', () => { onAsyncError: vi.fn(), }) - const oldSubmission = controller.generateCharacter('setup-1', { + const oldSubmission = controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64, }) await flushAsyncWork() expect(createGeneration).toHaveBeenCalledTimes(1) - await controller.restartFromNode('setup-1') - const newSubmission = controller.generateCharacter('setup-1', { + await controller.restartFromNode(nodeId) + const newSubmission = controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64, }) await flushAsyncWork() + expect(createGeneration).toHaveBeenCalledTimes(2) pendingResolvers[0]?.({ @@ -734,6 +822,22 @@ describe('WorkflowController', () => { }) }) + it('已归档 Action 的历史节点不能被重做为活动节点', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ status: 'passed', phase: 'completed', selectedFirstFrameUrl: 'walk.png' }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ status: 'passed', phase: 'completed' }), + reviewNode({ status: 'passed', phase: 'completed' }), + ]) + const { controller } = createController(run) + await controller.archiveAction('action-walk:action-full-frame') + + await expect(controller.restartFromNode('action-walk')).rejects.toThrow( + '已归档节点不能重新执行', + ) + }) + it('保存失败时不发布未落库的新状态', async () => { const run = createRun([ setupNode({ status: 'passed', phase: 'completed' }), @@ -743,7 +847,7 @@ describe('WorkflowController', () => { vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) await expect( - controller.confirmCharacter('template-1', 'https://img/knight.png'), + controller.confirmCharacterTemplate('template-1', 'https://img/knight.png'), ).rejects.toThrow('后端保存失败') expect(controller.getWorkflow().nodes[1]).toMatchObject({ @@ -759,14 +863,14 @@ describe('WorkflowController', () => { vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) await expect( - controller.generateActionFrame('action-walk', { + controller.generateFirstFrame('action-walk', { characterId: 'character-1', referenceMedia: [], }), ).rejects.toThrow('后端保存失败') expect(controller.getWorkflow().nodes[2].generations).toEqual([]) - await controller.generateActionFrame('action-walk', { + await controller.generateFirstFrame('action-walk', { characterId: 'character-1', referenceMedia: [], }) @@ -784,8 +888,8 @@ describe('WorkflowController', () => { const options = { characterId: 'character-1', referenceMedia: [] } await Promise.all([ - controller.generateActionFrame('action-walk', options), - controller.generateActionFrame('action-walk', options), + controller.generateFirstFrame('action-walk', options), + controller.generateFirstFrame('action-walk', options), ]) expect(generation.apis.create).toHaveBeenCalledTimes(1) @@ -814,7 +918,7 @@ describe('WorkflowController', () => { const { controller } = createController(run) await controller.applyGenerationResult({ - nodeId: 'action-walk:full-frame', + nodeId: 'action-walk:action-full-frame', taskId: 'task-animation', generation: { id: 'task-animation', @@ -826,7 +930,7 @@ describe('WorkflowController', () => { expect(controller.getWorkflow().nodes).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: 'action-walk:full-frame', + id: 'action-walk:action-full-frame', status: 'passed', phase: 'completed', }), @@ -838,18 +942,67 @@ describe('WorkflowController', () => { ]), ) - await controller.approveAction('action-walk:review') + await controller.approveReview('action-walk:review') expect(controller.getWorkflow().nodes[5]).toMatchObject({ status: 'passed', phase: 'completed', }) }) + it('一个并行 Action 失败不会阻止另一个 Action 接收生成结果', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + phase: 'generating', + generations: [{ taskId: 'task-walk', role: 'first_frame' }], + }), + firstFrameNode({ + id: 'action-jump', + phase: 'generating', + generations: [{ taskId: 'task-jump', role: 'first_frame' }], + input: actionInput({ name: '跳跃', type: 'jump' }), + }), + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk', + taskId: 'task-walk', + generation: { + id: 'task-walk', + projectId: '1', + type: 'first_frame', + status: 'failed', + result: null, + error: '行走首帧失败', + }, + }) + await controller.applyGenerationResult({ + nodeId: 'action-jump', + taskId: 'task-jump', + generation: { + id: 'task-jump', + projectId: '1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: 'jump.png' } }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'action-walk', status: 'failed' }), + expect.objectContaining({ id: 'action-jump', status: 'active', phase: 'selecting' }), + ]), + ) + }) + it('一个 Action 依次使用独立的首帧、生成方式、完整动画和审核节点', async () => { const run = createRun([...completedCharacterNodes(), ...actionNodes()]) const { controller, generation } = createController(run) - await controller.generateActionFrame('action-walk', { + await controller.generateFirstFrame('action-walk', { characterId: 'character-backend-1', referenceMedia: [], }) @@ -861,16 +1014,19 @@ describe('WorkflowController', () => { error: null, }) await flushAsyncWork() - await controller.confirmActionFrame('action-walk', 'https://img/first.png') - await controller.selectActionGenerationMethod('action-walk:generation-method', 'video-cropping') + await controller.confirmFirstFrame('action-walk', 'https://img/first.png') + await controller.selectActionGenerationMethod( + 'action-walk:action-generation-method', + 'video-cropping', + ) - await controller.generateAnimation('action-walk:full-frame', { + await controller.generateCompleteAnimation('action-walk:action-full-frame', { characterId: 'character-backend-1', referenceMedia: [], }) generation.emit(completedAnimationEvent()) await flushAsyncWork() - await controller.approveAction('action-walk:review') + await controller.approveReview('action-walk:review') expect(generation.apis.create).toHaveBeenNthCalledWith( 1, diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 55b2b004..ef4d1834 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -32,7 +32,7 @@ export interface AddActionInput { input: WorkflowActionInput } -export interface GenerateCharacterOptions { +export interface GenerateCharacterTemplateOptions { spriteWidth: number spriteHeight: number } @@ -71,19 +71,19 @@ export interface WorkflowController { getWorkflow(): WorkflowRun addAction(input: AddActionInput): Promise - generateCharacter( + generateCharacterTemplate( nodeId: CharacterSetupWorkflowNode['id'], - options: GenerateCharacterOptions, + options: GenerateCharacterTemplateOptions, ): Promise - confirmCharacter( + confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, ): Promise - generateActionFrame( + generateFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], options: GenerateActionOptions, ): Promise - confirmActionFrame( + confirmFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, ): Promise @@ -91,11 +91,11 @@ export interface WorkflowController { nodeId: ActionGenerationMethodWorkflowNode['id'], method: ActionGenerationMethod, ): Promise - generateAnimation( + generateCompleteAnimation( nodeId: ActionFullFrameWorkflowNode['id'], options: GenerateActionOptions, ): Promise - approveAction(nodeId: ReviewWorkflowNode['id']): Promise + approveReview(nodeId: ReviewWorkflowNode['id']): Promise /** 已发布 Action 删除后,保留其四节点历史并标记为已删除。 */ archiveAction(nodeId: ActionFullFrameWorkflowNode['id']): Promise @@ -197,8 +197,8 @@ 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 methodId = `${nodeId}:action-generation-method` + const fullFrameId = `${nodeId}:action-full-frame` const reviewId = `${nodeId}:review` const newIds = [nodeId, methodId, fullFrameId, reviewId] const duplicateId = newIds.find((id) => run.nodes.some((node) => node.id === id)) @@ -208,6 +208,12 @@ export function createWorkflowController({ : run.nodes.filter((node) => node.type === 'character-template').map((node) => node.id) if (dependencies.length === 0) throw new Error('新增 Action 前必须存在角色母版节点') assertDependenciesExist(run.nodes, dependencies) + if ( + dependencies.length !== 1 || + findNode(run, dependencies[0]!).type !== 'character-template' + ) { + throw new Error('Action 首帧必须且只能依赖一个角色母版节点') + } const firstFrameNode: ActionFirstFrameWorkflowNode = { id: nodeId, type: 'action-first-frame', @@ -254,9 +260,9 @@ export function createWorkflowController({ }) } - function generateCharacter( + function generateCharacterTemplate( nodeId: CharacterSetupWorkflowNode['id'], - options: GenerateCharacterOptions, + options: GenerateCharacterTemplateOptions, ): Promise { ensurePositiveInteger(options.spriteWidth, 'spriteWidth') ensurePositiveInteger(options.spriteHeight, 'spriteHeight') @@ -273,7 +279,7 @@ export function createWorkflowController({ async function performCharacterGeneration( nodeId: CharacterSetupWorkflowNode['id'], - options: GenerateCharacterOptions, + options: GenerateCharacterTemplateOptions, ): Promise { const before = requireWorkflow() const setupBefore = findNode(before, nodeId) @@ -308,7 +314,10 @@ export function createWorkflowController({ }) } - function confirmCharacter(nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string) { + function confirmCharacterTemplate( + nodeId: CharacterTemplateWorkflowNode['id'], + selectedImageUrl: string, + ) { ensureRunning() const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') return persist((run) => @@ -329,7 +338,7 @@ export function createWorkflowController({ ) } - function generateActionFrame( + function generateFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], options: GenerateActionOptions, ) { @@ -354,7 +363,7 @@ export function createWorkflowController({ }) } - function confirmActionFrame( + function confirmFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, ) { @@ -384,15 +393,15 @@ export function createWorkflowController({ ) { ensureRunning() if (method !== 'video-cropping' && method !== '3d-to-2d') { - return Promise.reject(new Error(`不支持的资产生成方式:${String(method)}`)) + return Promise.reject(new Error(`不支持的动作生成方式:${String(method)}`)) } return persist((run) => updateNode(run, nodeId, (node) => { if (node.type !== 'action-generation-method') { - throw new Error('目标节点不是资产生成方式') + throw new Error('目标节点不是动作生成方式') } if (node.status !== 'active' || node.phase !== 'selecting') { - throw new Error('资产生成方式节点当前不能选择') + throw new Error('动作生成方式节点当前不能选择') } return unlockReadyNodes( replaceNode(run, { ...node, method, status: 'passed', phase: 'completed' }), @@ -401,7 +410,7 @@ export function createWorkflowController({ ) } - function generateAnimation( + function generateCompleteAnimation( nodeId: ActionFullFrameWorkflowNode['id'], options: GenerateActionOptions, ) { @@ -410,7 +419,7 @@ export function createWorkflowController({ if (node.type !== 'action-full-frame') throw new Error('目标节点不是完整动画') if (node.phase !== 'ready') throw new Error('完整动画节点当前不能生成') const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') - if (!methodNode.method) throw new Error('尚未选择资产生成方式') + if (!methodNode.method) throw new Error('尚未选择动作生成方式') if (methodNode.method === '3d-to-2d') { throw new Error('3D 转 2D 接口尚未提供,暂时不能开始生成') } @@ -430,7 +439,7 @@ export function createWorkflowController({ }) } - function approveAction(nodeId: ReviewWorkflowNode['id']) { + function approveReview(nodeId: ReviewWorkflowNode['id']) { ensureRunning() return persist((run) => updateNode(run, nodeId, (node) => { @@ -730,8 +739,15 @@ export function createWorkflowController({ async function restartFromNode(nodeId: WorkflowNode['id']): Promise { const before = requireWorkflow() - findNode(before, nodeId) + const restartNode = findNode(before, nodeId) + if (restartNode.deletedAt) throw new Error('已归档节点不能重新执行') const affectedIds = collectDescendantIds(before.nodes, nodeId) + const affectedCharacterSetupIds = before.nodes + .filter( + (node): node is CharacterTemplateWorkflowNode => + node.type === 'character-template' && affectedIds.has(node.id), + ) + .flatMap((node) => node.dependsOnNodeIds) const restarted = await persist((run) => { const resetNodes = run.nodes.map((node) => @@ -749,6 +765,7 @@ export function createWorkflowController({ if (key.startsWith(`${affectedId}:`)) unattachedGenerations.delete(key) } } + for (const setupNodeId of affectedCharacterSetupIds) characterCommands.delete(setupNodeId) // 不依赖重做前快照里的 taskId:引用保存与重做交错时,订阅可能刚刚才建立。 for (const [key, subscription] of subscriptions) { if (affectedIds.has(subscription.nodeId)) stopSubscription(key) @@ -790,13 +807,13 @@ export function createWorkflowController({ create, getWorkflow, addAction, - generateCharacter, - confirmCharacter, - generateActionFrame, - confirmActionFrame, + generateCharacterTemplate, + confirmCharacterTemplate, + generateFirstFrame, + confirmFirstFrame, selectActionGenerationMethod, - generateAnimation, - approveAction, + generateCompleteAnimation, + approveReview, archiveAction, resume, interrupt, diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index d07245c6..3512474e 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -4,6 +4,6 @@ export type { ApplyGenerationResultInput, CreateWorkflowControllerOptions, GenerateActionOptions, - GenerateCharacterOptions, + GenerateCharacterTemplateOptions, WorkflowController, } from './controller' From b1c86bf06c5b9209e35996d6c1f2591492c1bc7d Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:31:47 +0800 Subject: [PATCH 10/11] fix(workflow-controller): unify state delivery --- .../src/entities/workflow-run/api.test.ts | 16 ++++ .../features/workflow-controller/README.md | 18 ++++ .../workflow-controller/controller.test.ts | 65 +++++++++++--- .../workflow-controller/controller.ts | 87 +++++++++++++------ 4 files changed, 146 insertions(+), 40 deletions(-) diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index 4a80c927..6be5c9fe 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -322,4 +322,20 @@ describe('workflowRunApis', () => { kind: 'invalid-response', }) }) + + it('rejects a passed node whose phase is not completed', async () => { + const passedCharacterStillConfiguring = { + ...nodes[0], + status: 'passed' as const, + phase: 'configuring' as const, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [passedCharacterStillConfiguring] }), + ) + + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) }) diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index 98fea11c..d7244fa7 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -11,6 +11,24 @@ 两者界面和交互不同,但不会各自维护另一套工作流状态机。Controller 本身也不保存 `driver`,因为“由谁点击”不改变节点图的业务规则。 +## 页面读取方式 + +页面只有一个持续更新渠道:先用 `getWorkflow()` 取得首屏快照,再用 `subscribe()` 接收 +后续保存和 SSE 写回。生成、确认、重做等方法是命令,只返回 `Promise`;它们不再 +额外返回另一份 `WorkflowRun`,因此页面不会在“命令返回值”和“Controller 当前状态”之间 +做选择。 + +```tsx +const [workflow, setWorkflow] = useState(() => controller.getWorkflow()) + +useEffect(() => controller.subscribe(setWorkflow), [controller]) + +async function generateCharacter() { + await controller.generateCharacterTemplate(setupNodeId, { spriteWidth: 64, spriteHeight: 64 }) + // 生成中和 SSE 终态都会由 subscribe 更新 workflow。 +} +``` + ## 边界 - `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index a165f2f6..381060f0 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -251,6 +251,47 @@ async function flushAsyncWork() { } describe('WorkflowController', () => { + it('页面通过订阅接收命令保存和 SSE 写回后的同一份 WorkflowRun', async () => { + const { controller, generation } = createController() + let renderedWorkflow = controller.getWorkflow() + const unsubscribe = controller.subscribe((workflow) => { + renderedWorkflow = workflow + }) + + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + + expect(renderedWorkflow.nodes[1]).toMatchObject({ + type: 'character-template', + phase: 'generating', + }) + + 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(renderedWorkflow.nodes[1]).toMatchObject({ + type: 'character-template', + phase: 'selecting', + }) + unsubscribe() + }) + + it('修改命令不再返回第二份 WorkflowRun', async () => { + const { controller } = createController(createRun(completedCharacterNodes())) + + await expect( + controller.addAction({ nodeId: 'action-walk', input: actionInput() }), + ).resolves.toBeUndefined() + }) + it('一个实例只绑定一条 WorkflowRun,创建后不能换成另一条', async () => { const workflow = createWorkflowApis() const generation = createGenerationHarness() @@ -260,9 +301,9 @@ describe('WorkflowController', () => { onAsyncError: vi.fn(), }) - const created = await controller.create({ projectId: '1', nodes: characterNodes() }) + await controller.create({ projectId: '1', nodes: characterNodes() }) - expect(controller.getWorkflow()).toEqual(created) + expect(controller.getWorkflow()).toMatchObject({ id: 'run-1', projectId: '1' }) await expect( controller.create({ projectId: '2', @@ -274,9 +315,9 @@ describe('WorkflowController', () => { 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() }) + await controller.addAction({ nodeId: 'action-walk', input: actionInput() }) - expect(next.nodes.slice(2)).toMatchObject([ + expect(controller.getWorkflow().nodes.slice(2)).toMatchObject([ { id: 'action-walk', type: 'action-first-frame', @@ -353,7 +394,8 @@ describe('WorkflowController', () => { ]) const { controller, workflow } = createController(run) - const archived = await controller.archiveAction('action-walk:action-full-frame') + await controller.archiveAction('action-walk:action-full-frame') + const archived = controller.getWorkflow() expect(archived.nodes.filter((node) => node.deletedAt).map((node) => node.id)).toEqual([ 'action-walk', @@ -408,9 +450,9 @@ describe('WorkflowController', () => { ]) const { controller } = createController(run) - const next = await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') + await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') - expect(next.nodes).toEqual( + expect(controller.getWorkflow().nodes).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'template-1', status: 'passed', phase: 'completed' }), expect.objectContaining({ id: 'action-walk', status: 'active' }), @@ -471,9 +513,8 @@ describe('WorkflowController', () => { await expect( controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }), ).rejects.toThrow('生成服务暂时不可用') - await expect( - controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }), - ).resolves.toMatchObject({ + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + expect(controller.getWorkflow()).toMatchObject({ nodes: expect.arrayContaining([ expect.objectContaining({ id: 'template-1', @@ -652,9 +693,9 @@ describe('WorkflowController', () => { ]) const { controller } = createController(run) - const restarted = await controller.restartFromNode('action-walk:action-generation-method') + await controller.restartFromNode('action-walk:action-generation-method') - expect(restarted.nodes).toEqual( + expect(controller.getWorkflow().nodes).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'action-walk:action-generation-method', diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index ef4d1834..b66883e4 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -67,44 +67,46 @@ export interface CreateWorkflowControllerOptions { * 调用、后者等待用户逐步点击。Controller 不识别入口,也不保存第二份流程模型。 */ export interface WorkflowController { - create(input: CreateWorkflowRunInput): Promise + create(input: CreateWorkflowRunInput): Promise + /** 页面首次读取当前快照;后续变化统一通过 subscribe 接收。 */ getWorkflow(): WorkflowRun + subscribe(listener: (workflow: WorkflowRun) => void): () => void - addAction(input: AddActionInput): Promise + addAction(input: AddActionInput): Promise generateCharacterTemplate( nodeId: CharacterSetupWorkflowNode['id'], options: GenerateCharacterTemplateOptions, - ): Promise + ): Promise confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, - ): Promise + ): Promise generateFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], options: GenerateActionOptions, - ): Promise + ): Promise confirmFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], selectedFirstFrameUrl: string, - ): Promise + ): Promise selectActionGenerationMethod( nodeId: ActionGenerationMethodWorkflowNode['id'], method: ActionGenerationMethod, - ): Promise + ): Promise generateCompleteAnimation( nodeId: ActionFullFrameWorkflowNode['id'], options: GenerateActionOptions, - ): Promise - approveReview(nodeId: ReviewWorkflowNode['id']): Promise + ): Promise + approveReview(nodeId: ReviewWorkflowNode['id']): Promise /** 已发布 Action 删除后,保留其四节点历史并标记为已删除。 */ - archiveAction(nodeId: ActionFullFrameWorkflowNode['id']): Promise + archiveAction(nodeId: ActionFullFrameWorkflowNode['id']): Promise /** 刷新恢复时查询已记录的 Generation,再恢复 SSE。 */ - resume(): Promise + resume(): Promise /** 停止本实例的自动处理;后端没有 cancel,所以不会伪装成取消了服务端任务。 */ - interrupt(): Promise - restartFromNode(nodeId: WorkflowNode['id']): Promise - applyGenerationResult(input: ApplyGenerationResultInput): Promise + interrupt(): Promise + restartFromNode(nodeId: WorkflowNode['id']): Promise + applyGenerationResult(input: ApplyGenerationResultInput): Promise getGeneration( nodeId: WorkflowNode['id'], role: WorkflowGenerationRole, @@ -142,6 +144,7 @@ export function createWorkflowController({ const nodeEpochs = new Map() const unattachedGenerations = new Map() const settlements = new Map>() + const listeners = new Set<(workflow: WorkflowRun) => void>() function requireWorkflow(): WorkflowRun { if (!current) throw new Error('WorkflowController 尚未绑定 WorkflowRun') @@ -152,6 +155,22 @@ export function createWorkflowController({ return structuredClone(requireWorkflow()) } + function notifyListeners() { + for (const listener of listeners) { + try { + listener(snapshot()) + } catch (cause) { + onAsyncError(asError(cause)) + } + } + } + + function subscribe(listener: (workflow: WorkflowRun) => void) { + listeners.add(listener) + listener(snapshot()) + return () => listeners.delete(listener) + } + function ensureRunning() { if (interrupted) throw new Error('WorkflowController 已中断,请先调用 resume') } @@ -174,6 +193,7 @@ export function createWorkflowController({ // 只有后端确认保存后才替换内存快照;失败时页面不会看到“假成功”。 const saved = await workflowRunApis.update(candidate) current = structuredClone(saved) + notifyListeners() return structuredClone(saved) }) } @@ -186,6 +206,7 @@ export function createWorkflowController({ nodes: normalizeAvailability(input.nodes), }) current = structuredClone(created) + notifyListeners() return structuredClone(created) }) } @@ -797,6 +818,7 @@ export function createWorkflowController({ function dispose() { interrupted = true stopAllSubscriptions() + listeners.clear() } function nodeEpoch(nodeId: WorkflowNode['id']) { @@ -804,26 +826,35 @@ export function createWorkflowController({ } return { - create, + create: asCommand(create), getWorkflow, - addAction, - generateCharacterTemplate, - confirmCharacterTemplate, - generateFirstFrame, - confirmFirstFrame, - selectActionGenerationMethod, - generateCompleteAnimation, - approveReview, - archiveAction, - resume, - interrupt, - restartFromNode, - applyGenerationResult, + subscribe, + addAction: asCommand(addAction), + generateCharacterTemplate: asCommand(generateCharacterTemplate), + confirmCharacterTemplate: asCommand(confirmCharacterTemplate), + generateFirstFrame: asCommand(generateFirstFrame), + confirmFirstFrame: asCommand(confirmFirstFrame), + selectActionGenerationMethod: asCommand(selectActionGenerationMethod), + generateCompleteAnimation: asCommand(generateCompleteAnimation), + approveReview: asCommand(approveReview), + archiveAction: asCommand(archiveAction), + resume: asCommand(resume), + interrupt: asCommand(interrupt), + restartFromNode: asCommand(restartFromNode), + applyGenerationResult: asCommand(applyGenerationResult), getGeneration, dispose, } } +function asCommand( + operation: (...args: TArgs) => Promise, +): (...args: TArgs) => Promise { + return async (...args) => { + await operation(...args) + } +} + function collectActionBranchIds( nodes: readonly WorkflowNode[], fullFrameNodeId: ActionFullFrameWorkflowNode['id'], From b239575bd9311bda7513640ef6bca7478df05f78 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:20:27 +0800 Subject: [PATCH 11/11] fix(workflow-controller): preserve archived branches --- .../workflow-controller/controller.test.ts | 96 +++++++++++++++++++ .../workflow-controller/controller.ts | 12 ++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 381060f0..713d76c2 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -879,6 +879,102 @@ describe('WorkflowController', () => { ) }) + it.each([ + ['角色设定', 'setup-1'], + ['角色母版', 'template-1'], + ])('从共享%s节点重做时完整保留已归档 Action 历史', async (_label, nodeId) => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + generations: [{ taskId: 'task-first-frame', role: 'first_frame' }], + selectedFirstFrameUrl: 'walk.png', + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'passed', + phase: 'completed', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode({ status: 'passed', phase: 'completed' }), + ]) + const { controller } = createController(run) + await controller.archiveAction('action-walk:action-full-frame') + const archivedBefore = controller + .getWorkflow() + .nodes.filter((node) => node.deletedAt) + .map((node) => structuredClone(node)) + + await controller.restartFromNode(nodeId) + + expect(controller.getWorkflow().nodes.filter((node) => node.deletedAt)).toEqual(archivedBefore) + }) + + it('归档节点即使状态异常变为 active 也不能再次提交生成任务', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'active', + deletedAt: '2026-08-09T00:00:00.000Z', + }), + ]) + const { controller, generation } = createController(run) + + await expect( + controller.generateFirstFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }), + ).rejects.toThrow('已归档节点不能执行') + expect(generation.apis.create).not.toHaveBeenCalled() + }) + + it('创建或恢复节点可用性时不会激活已归档节点', async () => { + const workflow = createWorkflowApis(createRun([])) + const generation = createGenerationHarness() + const controller = createWorkflowController({ + workflowRunApis: workflow.apis, + generationApis: generation.apis, + onAsyncError: vi.fn(), + }) + + await controller.create({ + projectId: '1', + nodes: [ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'locked', + deletedAt: '2026-08-09T00:00:00.000Z', + }), + ], + }) + + expect(controller.getWorkflow().nodes[2]).toMatchObject({ + status: 'locked', + deletedAt: '2026-08-09T00:00:00.000Z', + }) + }) + + it('上游节点完成并解锁下游时跳过已归档节点', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ status: 'active', phase: 'selecting' }), + firstFrameNode({ + status: 'locked', + deletedAt: '2026-08-09T00:00:00.000Z', + }), + ]) + const { controller } = createController(run) + + await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') + + expect(controller.getWorkflow().nodes[2]).toMatchObject({ + status: 'locked', + deletedAt: '2026-08-09T00:00:00.000Z', + }) + }) + it('保存失败时不发布未落库的新状态', async () => { const run = createRun([ setupNode({ status: 'passed', phase: 'completed' }), diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index b66883e4..251f85a8 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -670,6 +670,7 @@ export function createWorkflowController({ return persist((run) => { if (generation.id !== taskId || generation.projectId !== run.projectId) return run const node = findNode(run, nodeId) + if (node.deletedAt) return run const reference = node.generations.find((item) => item.taskId === taskId) if (!reference || node.status !== 'active') return run // 每种生成任务只属于一种节点;旧任务不能推进另一张卡片。 @@ -742,7 +743,7 @@ export function createWorkflowController({ } const run = requireWorkflow() const tasks = run.nodes.flatMap((node) => { - if (node.status !== 'active' || !isGeneratingPhase(node)) return [] + if (node.deletedAt || node.status !== 'active' || !isGeneratingPhase(node)) return [] const role = generationRoleForNode(node) if (!role) return [] const reference = node.generations.find((item) => item.role === role) @@ -896,7 +897,9 @@ function updateNode( nodeId: WorkflowNode['id'], update: (node: WorkflowNode) => WorkflowRun, ) { - return update(findNode(run, nodeId)) + const node = findNode(run, nodeId) + if (node.deletedAt) throw new Error('已归档节点不能执行') + return update(node) } function findNode(run: WorkflowRun, nodeId: WorkflowNode['id']): WorkflowNode { @@ -920,6 +923,7 @@ function unlockReadyNodes(run: WorkflowRun): WorkflowRun { return { ...run, nodes: run.nodes.map((node) => + !node.deletedAt && node.status === 'locked' && node.dependsOnNodeIds.every((dependencyId) => isPassed(run.nodes, dependencyId)) ? { ...node, status: 'active' } @@ -930,6 +934,7 @@ function unlockReadyNodes(run: WorkflowRun): WorkflowRun { function normalizeAvailability(nodes: readonly WorkflowNode[]): WorkflowNode[] { return nodes.map((node) => { + if (node.deletedAt) return structuredClone(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' } @@ -948,6 +953,7 @@ function assertDependenciesExist(nodes: readonly WorkflowNode[], dependencyIds: } function assertNodeCanRun(run: WorkflowRun, node: WorkflowNode) { + if (node.deletedAt) throw new Error('已归档节点不能执行') if (node.status !== 'active') throw new Error('目标节点当前不可执行') if (!node.dependsOnNodeIds.every((id) => isPassed(run.nodes, id))) { throw new Error('目标节点的前置依赖尚未完成') @@ -1022,7 +1028,7 @@ function collectDescendantIds(nodes: readonly WorkflowNode[], rootId: string) { while (changed) { changed = false for (const node of nodes) { - if (affected.has(node.id)) continue + if (node.deletedAt || affected.has(node.id)) continue if (node.dependsOnNodeIds.some((id) => affected.has(id))) { affected.add(node.id) changed = true