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/entities/index.ts b/frontend/src/entities/index.ts index 4125ea91..8e7c3c79 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -56,9 +56,14 @@ export type { MediaReference } from './media' /* 工作流 —— 前端管理节点,后端只持久化完整 nodes 文档 */ export { workflowRunApis } from './workflow-run' export type { - ActionWorkflowNode, - CharacterWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + ActionGenerationMethod, + ActionGenerationMethodWorkflowNode, + 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..b0a05bc0 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -6,11 +6,15 @@ - 前后端统一使用 `WorkflowNode`。原先前端的 Step 与后端的 Node 是同一概念,已经合并。 - `WorkflowRun.nodes` 直接保存真实节点,不再使用 `root.steps` 或人为包装的根节点。 -- 一个节点与 Workflow Editor 中一张卡片一一对应;生成与选择是节点内部 phase,不拆成额外节点。 +- 六类节点与 Workflow Editor 的六类卡片一一对应:角色设定、角色母版、动作首帧、资产生成方式、完整动画和审核。 +- “提交中、生成中、选择中”仍是节点内部 phase,不再拆成 Step 或额外任务节点。 - 节点通过 `dependsOnNodeIds` 保存直接前置依赖,因此边会与节点一起落库,不再依赖数组顺序猜测连线。 -- 多个 Action 节点可以依赖同一个角色节点;前置节点通过后即可并行,不互相阻塞。 +- 每个 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` 防止旧请求串线。 +- 已发布 Action 被删除后,对应四节点分支以 `deletedAt` 标记并继续留在图中,生成输入、任务引用和审核历史不会被擦除;角色母版及其他并行动作不受影响。 ## 前后端边界 @@ -25,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/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index 51bb1bc3..6be5c9fe 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -3,37 +3,63 @@ 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-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_animation', - dependsOnNodeIds: ['character-node'], - generations: [{ taskId: '93', role: 'animation' }], + phase: 'generating', + dependsOnNodeIds: ['walk-generation-method'], + 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 +90,85 @@ function jsonResponse(data: unknown) { } describe('workflowRunApis', () => { + it('hydrates the six visible workflow nodes with explicit dependency edges', async () => { + const sixNodeDto = { + ...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: '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: ['generation-method-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(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-generation-method', dependsOnNodeIds: ['first-frame-1'] }, + { type: 'action-full-frame', dependsOnNodeIds: ['generation-method-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) => { @@ -133,12 +238,28 @@ 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({ ...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 +274,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 +287,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 +295,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 +311,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 () => @@ -201,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/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index 2e64314b..944d6dc9 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -1,7 +1,11 @@ import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' import type { - ActionWorkflowNode, - CharacterWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + ActionGenerationMethodWorkflowNode, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, + ReviewWorkflowNode, WorkflowNode, WorkflowRun, WorkflowRunApis, @@ -53,74 +57,138 @@ 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 } + 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.input.prompt === 'string' && - Array.isArray(value.input.referenceMedia) && - value.input.referenceMedia.every((item) => typeof item === 'string') && + 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.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 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) && + 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) || + isActionGenerationMethodNode(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..51d4ed25 100644 --- a/frontend/src/entities/workflow-run/constants.ts +++ b/frontend/src/entities/workflow-run/constants.ts @@ -3,25 +3,30 @@ /** 后端资源状态只表达是否被软删除,不等同于前端节点状态。 */ 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-generation-method', + '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..68f72ba7 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'] @@ -33,6 +36,11 @@ interface WorkflowNodeBase { dependsOnNodeIds: string[] generations: WorkflowGenerationRef[] error: string | null + /** + * 已发布资产被用户删除的时间。节点仍保留生成输入、任务引用与审核历史; + * 旧数据没有该字段时视为未删除。 + */ + deletedAt?: string | null } export interface WorkflowCharacterInput { @@ -40,10 +48,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 +70,41 @@ 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 ActionGenerationMethodWorkflowNode extends WorkflowNodeBase { + type: 'action-generation-method' + phase: 'selecting' | 'completed' + method: ActionGenerationMethod | 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 + | ActionGenerationMethodWorkflowNode + | ActionFullFrameWorkflowNode + | ReviewWorkflowNode /** * 一次制作流程的持久化容器。Quick Start 与 Workflow Editor 只是不同界面; diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md new file mode 100644 index 00000000..d7244fa7 --- /dev/null +++ b/frontend/src/features/workflow-controller/README.md @@ -0,0 +1,51 @@ +# WorkflowController + +`WorkflowController` 是页面与实体接口之间的业务协调器。一个实例只绑定一条 +`WorkflowRun`;它同时持有当前数据和修改这份数据的业务方法。 + +## 两种入口 + +- Workflow Editor 等待用户逐步调用生成、确认和审核方法。 +- Quick Start 用 AI 自动做选择并连续调用同一组方法。 + +两者界面和交互不同,但不会各自维护另一套工作流状态机。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,不包含推进方法。 +- Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 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,可由 `resume()` 继续。当前后端没有取消接口,因此不会伪装成已取消任务; + 恢复时先订阅再查询任务快照,既能拿终态,也不会漏掉查询与订阅之间的完成事件。 +- 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..713d76c2 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,1249 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + ActionGenerationMethodWorkflowNode, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, + Generation, + GenerationApis, + GenerationEvent, + ReviewWorkflowNode, + WorkflowActionInput, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { createWorkflowController } from '.' + +function setupNode( + overrides: Partial = {}, +): CharacterSetupWorkflowNode { + return { + id: 'setup-1', + type: 'character-setup', + status: 'active', + 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, + } +} + +function actionInput(overrides: Partial = {}): WorkflowActionInput { + return { + outfitId: 'outfit-1', + name: '行走', + type: 'walk', + prompt: null, + fps: 12, + ...overrides, + } +} + +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:action-full-frame', + type: 'action-full-frame', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['action-walk:action-generation-method'], + generations: [], + error: null, + ...overrides, + } +} + +function generationMethodNode( + overrides: Partial = {}, +): ActionGenerationMethodWorkflowNode { + return { + id: 'action-walk:action-generation-method', + type: 'action-generation-method', + status: 'locked', + phase: 'selecting', + dependsOnNodeIds: ['action-walk'], + generations: [], + error: null, + method: null, + ...overrides, + } +} + +function reviewNode(overrides: Partial = {}): ReviewWorkflowNode { + return { + id: 'action-walk:review', + type: 'review', + status: 'locked', + phase: 'reviewing', + dependsOnNodeIds: ['action-walk:action-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(), generationMethodNode(), fullFrameNode(), reviewNode()] +} + +function createRun(nodes: WorkflowNode[] = characterNodes()): 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', + now: () => '2026-08-09T00:00:00.000Z', + onAsyncError: (error) => asyncErrors.push(error), + }) + 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)) +} + +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() + const controller = createWorkflowController({ + workflowRunApis: workflow.apis, + generationApis: generation.apis, + onAsyncError: vi.fn(), + }) + + await controller.create({ projectId: '1', nodes: characterNodes() }) + + expect(controller.getWorkflow()).toMatchObject({ id: 'run-1', projectId: '1' }) + await expect( + controller.create({ + projectId: '2', + nodes: [setupNode({ id: 'other-setup' }), templateNode({ id: 'other-template' })], + }), + ).rejects.toThrow('已经绑定') + }) + + it('adds a complete first-frame, method, full-frame, and review chain for one Action', async () => { + const { controller } = createController(createRun(completedCharacterNodes())) + + await controller.addAction({ nodeId: 'action-walk', input: actionInput() }) + + expect(controller.getWorkflow().nodes.slice(2)).toMatchObject([ + { + id: 'action-walk', + type: 'action-first-frame', + status: 'active', + dependsOnNodeIds: ['template-1'], + }, + { + id: 'action-walk:action-generation-method', + type: 'action-generation-method', + status: 'locked', + dependsOnNodeIds: ['action-walk'], + method: null, + }, + { + id: 'action-walk:action-full-frame', + type: 'action-full-frame', + status: 'locked', + dependsOnNodeIds: ['action-walk:action-generation-method'], + }, + { + id: 'action-walk:review', + type: 'review', + status: 'locked', + 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(), + 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:action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-jump'], + method: 'video-cropping', + }), + fullFrameNode({ + id: 'action-jump:action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-jump:action-generation-method'], + }), + reviewNode({ + id: 'action-jump:review', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-jump:action-full-frame'], + }), + ]) + const { controller, workflow } = createController(run) + + 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', + 'action-walk:action-generation-method', + 'action-walk:action-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(), + 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:action-generation-method', + '3d-to-2d', + ) + + await expect( + controller.generateCompleteAnimation('action-walk:action-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' }), + templateNode({ status: 'active', phase: 'selecting' }), + firstFrameNode({ id: 'action-walk', status: 'locked' }), + firstFrameNode({ + id: 'action-jump', + status: 'locked', + input: actionInput({ name: '跳跃', type: 'jump' }), + }), + ]) + const { controller } = createController(run) + + await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') + + expect(controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + 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 () => { + const { controller, workflow, generation, asyncErrors } = createController() + + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + + expect(generation.apis.create).toHaveBeenCalledWith( + 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' }], + }), + ]), + ) + + 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[1]).toMatchObject({ + type: 'character-template', + status: 'active', + phase: 'selecting', + error: null, + }) + expect(asyncErrors).toEqual([]) + }) + + it('角色设定已落库但生成请求失败后可以重试', async () => { + const { controller, generation } = createController() + vi.mocked(generation.apis.create).mockRejectedValueOnce(new Error('生成服务暂时不可用')) + + await expect( + controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('生成服务暂时不可用') + await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + expect(controller.getWorkflow()).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.generateCharacterTemplate('setup-1', options), + controller.generateCharacterTemplate('setup-1', options), + ]) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + }) + + 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.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + + expect(workflow.apis.update).toHaveBeenCalledTimes(3) + expect(controller.getWorkflow().nodes[1].phase).toBe('selecting') + }) + + it('中断后忽略迟到结果,恢复时查询终态再推进', async () => { + const { controller, generation } = createController() + await controller.generateCharacterTemplate('setup-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[1].phase).toBe('generating') + + await controller.resume() + expect(controller.getWorkflow().nodes[1].phase).toBe('selecting') + }) + + it('从母版节点重做会清空下游任务,旧事件不能覆盖新执行线', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-old', role: 'character_template' }], + }), + ...actionNodes().map((node) => ({ ...node, status: 'locked' as const })), + ]) + const { controller } = createController(run) + + await controller.restartFromNode('template-1') + await controller.applyGenerationResult({ + nodeId: 'template-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.arrayContaining([ + expect.objectContaining({ + id: 'template-1', + status: 'active', + phase: 'ready', + generations: [], + }), + expect.objectContaining({ id: 'action-walk', status: 'locked', generations: [] }), + expect.objectContaining({ + id: 'action-walk:action-full-frame', + status: 'locked', + generations: [], + }), + ]), + ) + }) + + 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) + + await controller.restartFromNode('action-walk:action-generation-method') + + expect(controller.getWorkflow().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) + 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.generateFirstFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }) + await Promise.resolve() + await controller.restartFromNode('action-walk') + + const newSubmission = controller.generateFirstFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }) + await Promise.resolve() + expect(createGeneration).toHaveBeenCalledTimes(2) + + pendingResolvers[0]?.({ + id: 'task-old', + projectId: '1', + type: 'first_frame', + status: 'pending', + result: null, + error: null, + }) + await oldSubmission + const sameNewSubmission = controller.generateFirstFrame('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.each([ + ['角色设定', 'setup-1'], + ['角色母版', 'template-1'], + ])('角色母版请求尚未返回时从%s node 重做,新提交不会复用旧命令', async (_label, nodeId) => { + 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.generateCharacterTemplate('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + await flushAsyncWork() + expect(createGeneration).toHaveBeenCalledTimes(1) + + await controller.restartFromNode(nodeId) + const newSubmission = controller.generateCharacterTemplate('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', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + await newSubmission + + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + id: 'template-1', + phase: 'generating', + generations: [{ taskId: 'task-new', role: 'character_template' }], + }) + }) + + 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.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' }), + templateNode({ status: 'active', phase: 'selecting' }), + ]) + const { controller, workflow } = createController(run) + vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) + + await expect( + controller.confirmCharacterTemplate('template-1', 'https://img/knight.png'), + ).rejects.toThrow('后端保存失败') + + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'selecting', + selectedImageUrl: null, + }) + }) + + it('生成任务创建成功但引用保存失败时,重试复用同一个任务', async () => { + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) + const { controller, workflow, generation } = createController(run) + vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) + + await expect( + controller.generateFirstFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }), + ).rejects.toThrow('后端保存失败') + expect(controller.getWorkflow().nodes[2].generations).toEqual([]) + + await controller.generateFirstFrame('action-walk', { + characterId: 'character-1', + referenceMedia: [], + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + expect(controller.getWorkflow().nodes[2]).toMatchObject({ + phase: 'generating', + generations: [{ taskId: 'task-1', role: 'first_frame' }], + }) + }) + + it('同一节点并发点击只创建一个生成任务', async () => { + const run = createRun([...completedCharacterNodes(), ...actionNodes()]) + const { controller, generation } = createController(run) + const options = { characterId: 'character-1', referenceMedia: [] } + + await Promise.all([ + controller.generateFirstFrame('action-walk', options), + controller.generateFirstFrame('action-walk', options), + ]) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + }) + + it('完整动画必须是 32 帧,完成后只解锁自己的审核节点', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'https://img/first.png', + }), + generationMethodNode({ + status: 'passed', + phase: 'completed', + method: 'video-cropping', + }), + fullFrameNode({ + status: 'active', + phase: 'generating', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode(), + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk:action-full-frame', + taskId: 'task-animation', + generation: { + id: 'task-animation', + projectId: '1', + ...completedAnimationEvent('task-animation'), + }, + }) + + expect(controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'action-walk:action-full-frame', + status: 'passed', + phase: 'completed', + }), + expect.objectContaining({ + id: 'action-walk:review', + status: 'active', + phase: 'reviewing', + }), + ]), + ) + + 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.generateFirstFrame('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.confirmFirstFrame('action-walk', 'https://img/first.png') + await controller.selectActionGenerationMethod( + 'action-walk:action-generation-method', + 'video-cropping', + ) + + await controller.generateCompleteAnimation('action-walk:action-full-frame', { + characterId: 'character-backend-1', + referenceMedia: [], + }) + generation.emit(completedAnimationEvent()) + await flushAsyncWork() + await controller.approveReview('action-walk:review') + + expect(generation.apis.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: 'first_frame', + characterId: 'character-backend-1', + outfitId: 'outfit-1', + referenceMedia: ['https://img/knight.png'], + }), + ) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'complete_animation', + firstFrameUrl: 'https://img/first.png', + }), + ) + expect(controller.getWorkflow().nodes.slice(2)).toMatchObject([ + { + type: 'action-first-frame', + status: 'passed', + generations: [{ taskId: 'task-1', role: 'first_frame' }], + }, + { + type: 'action-generation-method', + status: 'passed', + method: 'video-cropping', + }, + { + type: 'action-full-frame', + status: 'passed', + generations: [{ taskId: 'task-2', role: 'complete_animation' }], + }, + { type: 'review', status: 'passed' }, + ]) + }) + + it('恢复时只查询当前生成节点,不重复恢复已经通过的首帧任务', async () => { + const run = createRun([ + ...completedCharacterNodes(), + firstFrameNode({ + status: 'passed', + phase: 'completed', + 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', + generations: [{ taskId: 'task-animation', role: 'complete_animation' }], + }), + reviewNode(), + ]) + 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() + + expect(generation.apis.get).toHaveBeenCalledTimes(1) + expect(generation.apis.get).toHaveBeenCalledWith('1', 'task-animation') + 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 new file mode 100644 index 00000000..251f85a8 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,1108 @@ +import type { + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, + ActionGenerationMethod, + ActionGenerationMethodWorkflowNode, + CharacterTemplateGenerationInput, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, + CompleteAnimationGenerationInput, + CreateWorkflowRunInput, + FirstFrameGenerationInput, + Generation, + GenerationApis, + GenerationEvent, + MediaReference, + ReviewWorkflowNode, + WorkflowActionInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' + +const COMPLETE_ANIMATION_FRAME_COUNT = 32 + +export interface AddActionInput { + /** 首帧节点 ID;完整动画和审核节点在此 ID 后追加稳定后缀。 */ + nodeId?: WorkflowNode['id'] + /** 默认依赖当前图中已确认的角色母版节点。 */ + dependsOnNodeIds?: readonly WorkflowNode['id'][] + input: WorkflowActionInput +} + +export interface GenerateCharacterTemplateOptions { + 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 + now?: () => string + /** SSE 回调无法 await,异步保存错误通过此处交给装配层展示或记录。 */ + onAsyncError: (error: Error) => void +} + +/** + * 一个 Controller 只维护一条 WorkflowRun。 + * + * Quick Start 与 Workflow Editor 调用同一组业务方法,区别只在于前者自动选择并连续 + * 调用、后者等待用户逐步点击。Controller 不识别入口,也不保存第二份流程模型。 + */ +export interface WorkflowController { + create(input: CreateWorkflowRunInput): Promise + /** 页面首次读取当前快照;后续变化统一通过 subscribe 接收。 */ + getWorkflow(): WorkflowRun + subscribe(listener: (workflow: WorkflowRun) => void): () => void + + addAction(input: AddActionInput): Promise + generateCharacterTemplate( + nodeId: CharacterSetupWorkflowNode['id'], + options: GenerateCharacterTemplateOptions, + ): Promise + confirmCharacterTemplate( + nodeId: CharacterTemplateWorkflowNode['id'], + selectedImageUrl: string, + ): Promise + generateFirstFrame( + nodeId: ActionFirstFrameWorkflowNode['id'], + options: GenerateActionOptions, + ): Promise + confirmFirstFrame( + nodeId: ActionFirstFrameWorkflowNode['id'], + selectedFirstFrameUrl: string, + ): Promise + selectActionGenerationMethod( + nodeId: ActionGenerationMethodWorkflowNode['id'], + method: ActionGenerationMethod, + ): Promise + generateCompleteAnimation( + nodeId: ActionFullFrameWorkflowNode['id'], + options: GenerateActionOptions, + ): Promise + approveReview(nodeId: ReviewWorkflowNode['id']): Promise + /** 已发布 Action 删除后,保留其四节点历史并标记为已删除。 */ + archiveAction(nodeId: ActionFullFrameWorkflowNode['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, + now = () => new Date().toISOString(), + onAsyncError, +}: CreateWorkflowControllerOptions): WorkflowController { + 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() + 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') + return current + } + + function snapshot(): WorkflowRun { + 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') + } + + 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) + notifyListeners() + 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) + notifyListeners() + return structuredClone(created) + }) + } + + function getWorkflow() { + return snapshot() + } + + function addAction({ nodeId = createId(), dependsOnNodeIds, input }: AddActionInput) { + ensureRunning() + return persist((run) => { + 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)) + if (duplicateId) throw new Error(`WorkflowNode 已存在:${duplicateId}`) + const dependencies = dependsOnNodeIds + ? [...dependsOnNodeIds] + : 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', + status: dependencies.every((id) => isPassed(run.nodes, id)) ? 'active' : 'locked', + phase: 'configuring', + dependsOnNodeIds: dependencies, + generations: [], + error: null, + input: structuredClone(input), + selectedFirstFrameUrl: null, + } + const fullFrameNode: ActionFullFrameWorkflowNode = { + id: fullFrameId, + 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, + type: 'review', + status: 'locked', + phase: 'reviewing', + dependsOnNodeIds: [fullFrameNode.id], + generations: [], + error: null, + } + return { + ...run, + nodes: [...run.nodes, firstFrameNode, methodNode, fullFrameNode, reviewNode], + } + }) + } + + function generateCharacterTemplate( + nodeId: CharacterSetupWorkflowNode['id'], + options: GenerateCharacterTemplateOptions, + ): Promise { + ensurePositiveInteger(options.spriteWidth, 'spriteWidth') + ensurePositiveInteger(options.spriteHeight, 'spriteHeight') + 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: GenerateCharacterTemplateOptions, + ): 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: setupNode.input.prompt, + referenceMedia: setupNode.input.referenceMedia, + ...options, + } + return input + }) + } + + function confirmCharacterTemplate( + nodeId: CharacterTemplateWorkflowNode['id'], + selectedImageUrl: string, + ) { + ensureRunning() + const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'character-template') throw new Error('目标节点不是角色母版') + if (node.status !== 'active' || node.phase !== 'selecting') { + throw new Error('角色母版节点当前不能确认候选图') + } + return unlockReadyNodes({ + ...run, + nodes: run.nodes.map((item) => + item.id === node.id + ? { ...node, selectedImageUrl: imageUrl, phase: 'completed', status: 'passed' } + : item, + ), + }) + }), + ) + } + + function generateFirstFrame( + nodeId: ActionFirstFrameWorkflowNode['id'], + options: GenerateActionOptions, + ) { + const characterId = nonEmpty(options.characterId, 'characterId') + 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, + characterId, + outfitId: node.input.outfitId, + actionType: node.input.type, + prompt: node.input.prompt, + referenceMedia: [...new Set([characterTemplateReference, ...options.referenceMedia])], + } + return input + }) + } + + function confirmFirstFrame( + nodeId: ActionFirstFrameWorkflowNode['id'], + selectedFirstFrameUrl: string, + ) { + ensureRunning() + const imageUrl = nonEmpty(selectedFirstFrameUrl, 'selectedFirstFrameUrl') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'action-first-frame') throw new Error('目标节点不是动作首帧') + if (node.status !== 'active' || node.phase !== 'selecting') { + throw new Error('动作首帧节点当前不能确认首帧') + } + return unlockReadyNodes( + replaceNode(run, { + ...node, + selectedFirstFrameUrl: imageUrl, + status: 'passed', + phase: 'completed', + }), + ) + }), + ) + } + + 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 generateCompleteAnimation( + nodeId: ActionFullFrameWorkflowNode['id'], + options: GenerateActionOptions, + ) { + const characterId = nonEmpty(options.characterId, 'characterId') + return submitGeneration(nodeId, 'complete_animation', (run, node) => { + 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 === '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', + projectId: run.projectId, + characterId, + outfitId: firstFrameNode.input.outfitId, + actionType: firstFrameNode.input.type, + firstFrameUrl: firstFrameNode.selectedFirstFrameUrl, + prompt: firstFrameNode.input.prompt, + referenceMedia: options.referenceMedia, + } + return input + }) + } + + function approveReview(nodeId: ReviewWorkflowNode['id']) { + ensureRunning() + return persist((run) => + updateNode(run, nodeId, (node) => { + 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 }), + ) + }), + ) + } + + 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, + 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, attachGenerationReference(latestNode, generation.id, role)) + }) + 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) + if (node.deletedAt) return run + const reference = node.generations.find((item) => item.taskId === taskId) + if (!reference || node.status !== 'active') 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, { + ...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_template') { + if ( + 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', error: null }) + } + + if (reference.role === 'first_frame') { + if ( + 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', error: null }) + } + + if ( + node.type !== 'action-full-frame' || + 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 unlockReadyNodes( + replaceNode(run, { ...node, status: 'passed', phase: 'completed', 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.deletedAt || node.status !== 'active' || !isGeneratingPhase(node)) return [] + const role = generationRoleForNode(node) + if (!role) return [] + 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() + 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) => + affectedIds.has(node.id) ? resetNode(node) : node, + ) + return { ...run, nodes: normalizeAvailability(resetNodes) } + }) + 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) + } + for (const [key] of unattachedGenerations) { + 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) + } + 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() + listeners.clear() + } + + function nodeEpoch(nodeId: WorkflowNode['id']) { + return nodeEpochs.get(nodeId) ?? 0 + } + + return { + create: asCommand(create), + getWorkflow, + 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'], +) { + 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'], + update: (node: WorkflowNode) => WorkflowRun, +) { + const node = findNode(run, nodeId) + if (node.deletedAt) throw new Error('已归档节点不能执行') + return update(node) +} + +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.deletedAt && + 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.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' } + }) +} + +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.deletedAt) throw new Error('已归档节点不能执行') + if (node.status !== 'active') throw new Error('目标节点当前不可执行') + if (!node.dependsOnNodeIds.every((id) => isPassed(run.nodes, id))) { + throw new Error('目标节点的前置依赖尚未完成') + } +} + +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 assertGenerationRoleMatchesNode(node: WorkflowNode, role: WorkflowGenerationRole) { + if (generationRoleForNode(node) !== role) { + throw new Error(`生成任务 ${role} 不能绑定到 ${node.type} 节点`) + } +} + +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) { + const affected = new Set([rootId]) + let changed = true + while (changed) { + changed = false + for (const node of nodes) { + if (node.deletedAt || 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-setup') { + return { + ...node, + status: 'locked', + phase: 'configuring', + generations: [], + error: null, + } + } + if (node.type === 'character-template') { + return { + ...node, + status: 'locked', + phase: 'ready', + generations: [], + error: null, + selectedImageUrl: null, + } + } + if (node.type === 'action-first-frame') { + return { + ...node, + status: 'locked', + phase: 'configuring', + generations: [], + error: null, + 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 } + } + return { ...node, status: 'locked', phase: 'reviewing', generations: [], error: 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..3512474e 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, + GenerateCharacterTemplateOptions, + WorkflowController, +} from './controller' 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,它目前只有接口没有实现。 - */} { + 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 ( +
+ + +
+ ) +}