diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359d..bf05b43 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,6 +1,12 @@ /** - * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * Entity 层的唯一公开入口。 + * + * Page 和 Feature 只从 `@/entities` 导入,不直接访问某个 Entity 的内部文件。 + * 这不是为了少写一段路径,而是为了稳定模块边界:内部文件可以重构, + * 但公开名称和依赖方向必须经过本文件明确审核。 + * + * 这里只暴露 Entity 级别的数据结构、后端端口契约以及必要的本地 Store 工厂。 + * 页面状态、路由、弹窗和按钮行为不属于 Entity,不应从此处导出。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ @@ -55,10 +61,28 @@ export type { /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' -/* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +/* + * 工作流 —— 记录“一次用户任务如何运行”。 + * 它不是角色/动作资产,也不是负责调后端的 WorkflowController。 + */ +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + createWorkflowRunService, + createWorkflowRunStore, + WORKFLOW_STEP_ORDERS, +} from './workflow-run' export type { + ActionFirstFrameCandidateBatch, + ActionReviewFrame, + ActionReviewResult, + CreateWorkflowRunStoreOptions, + CreateWorkflowRunServiceOptions, CreateWorkflowRunInput, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, ExportStatus, GenerationStatus, WorkflowDriver, @@ -68,6 +92,11 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, + WorkflowRunService, WorkflowRunPurpose, WorkflowRunStatus, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b..00d9292 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,157 +1,43 @@ -import type { Generation } from '../generation' - -/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' - -/** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' - -/** - * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 - */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const - -/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ -export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] - -/** - * 步骤的可用性和执行结果;不直接复用后端任务状态。 - * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 - */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' - -/** - * 单个版本的生命周期。 - * abandoned 表示停止沿用但仍保留为历史。 - */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' - -/** - * 整次流程的汇总状态。 - * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 - */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' - /** - * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 - * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。 - */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' - -/** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' - -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { - /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ - id: string - type: WorkflowStepType - status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown - /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 - * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 - * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 - */ - taskId: Generation['id'] | null - /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ - referenceStepIds: string[] -} - -/** - * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 + * WorkflowRun Entity 的对外入口。 * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 - */ -export interface WorkflowRevision { - id: string - /** 首次创建的版本没有来源,因此为 null。 */ - basedOnRevisionId: string | null - /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ - restartStepId: string | null - status: WorkflowRevisionStatus - /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 - */ - steps: WorkflowStep[] - generationStatus: GenerationStatus - exportStatus: ExportStatus - createdAt: string -} - -/** - * 一次由前端推进的页面流程。 - * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 - * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 - */ -export interface WorkflowRun { - id: string - projectId: string - /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ - characterId: string | null - /** 已有角色加动作时的目标造型;新建角色时为 null。 */ - outfitId: string | null - purpose: WorkflowRunPurpose - driver: WorkflowDriver - status: WorkflowRunStatus - /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ - currentRevisionId: string - /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ - revisions: WorkflowRevision[] - /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ - prompt: string | null -} - -/** 两种入口共享的创建字段。 */ -interface CreateWorkflowRunInputBase { - projectId: string - driver: WorkflowDriver - /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ - prompt?: string -} - -/** - * 创建 WorkflowRun 的输入。 - * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 - */ -export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & - ( - | { - purpose: 'create_character' - characterId?: never - outfitId?: never - characterTemplateUrl?: never - baseFrameUrls?: never - } - | { - purpose: 'add_action' - characterId: string - outfitId: string - characterTemplateUrl: string - baseFrameUrls: readonly string[] - } - ) + * 外部模块只从这里获取 WorkflowRun 能力,不绕过入口直接依赖 model/store + * 内部文件。这样既保留了子目录的职责分工,又不把内部结构变成全仓库 API。 + */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './model' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, +} from './model' +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' +export { createWorkflowRunService } from './service' +export type { + ActionFirstFrameCandidateBatch, + ActionReviewFrame, + ActionReviewResult, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, + CreateWorkflowRunServiceOptions, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + WorkflowRunService, +} from './service' diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts new file mode 100644 index 0000000..375bda3 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -0,0 +1,63 @@ +/** + * WorkflowRun 的业务词汇和步骤模板。 + * + * 常量数组同时服务于三个地方:TypeScript 联合类型、运行时水合校验、 + * 以及页面的进度顺序。只保留一份定义,可以避免“类型说可以,恢复时却拒绝”。 + */ + +/** 该 Run 是由 AI 自动引导,还是用户在编辑器中手动推进。 */ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const + +/** + * 一个 Run 只有一个目标。新建角色和追加动作可在同一界面连续操作, + * 但是两次独立任务,因此使用两个 WorkflowRun。 + */ +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const + +/** Run 级状态:描述整个用户任务,不等于某次后端生成任务的状态。 */ +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const + +/** + * Revision 级状态。用户从旧步骤重做时,旧 Revision 变为 abandoned, + * 并追加新 Revision;不覆盖历史,才能说清“这个结果从哪次重做而来”。 + */ +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const + +/** 当前 Revision 中生成阶段的汇总状态,不是单个 GenerationTask.status。 */ +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const + +/** 导出阶段的汇总状态;角色生成 Run 没有导出步骤时保持 not_exported。 */ +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const + +/** + * 单个步骤的状态。locked 表示前置条件未满足,available 表示可开始, + * active 表示当前正在处理,passed/failed 是已结束结果。 + */ +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 角色形象每次生成 4 张临时候选;用户只会确认其中 1 张为正式资产。 */ +export const CHARACTER_CANDIDATE_COUNT = 4 + +/** 动作也先生成 4 张独立首帧,避免错误姿势直接扩展成完整动画。 */ +export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 + +/** + * 按任务目的分开定义步骤顺序。 + * + * create_character 到“四选一并保存正式角色”就结束; + * add_action 从已有角色/造型开始,不重复跑角色母版生成。 + * + * 两个 Run 可以由同一页面连续展示,但数据上必须拆开,否则历史记录、 + * 失败重试和后续追加动作都无法准确归属。 + */ +export const WORKFLOW_STEP_ORDERS = { + create_character: ['character-setup', 'character-template', 'template-candidate'], + add_action: [ + 'action-setup', + 'first-frame', + 'first-frame-candidate', + 'complete-animation', + 'review', + 'export', + ], +} as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts new file mode 100644 index 0000000..fe954e6 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -0,0 +1,27 @@ +/** + * WorkflowRun 领域模型的子目录入口。 + * + * 本目录只定义“WorkflowRun 是什么”:业务词汇、步骤模板、Run/Revision/Step + * 类型以及创建输入。它不知道 localStorage、订阅者或页面,因此可被 + * Store、Controller 和页面共同依赖,而不产生反向依赖。 + */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, +} from './types' diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts new file mode 100644 index 0000000..efda404 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -0,0 +1,205 @@ +/** + * WorkflowRun Entity 的公开业务模型。 + * + * 层级关系是 WorkflowRun(一次用户任务) -> WorkflowRevision(一条重做版本) + * -> WorkflowStep(版本中的一个步骤)。后端 Generation 只是某个步骤引用的异步任务, + * 不能代替 WorkflowRun;角色和动作是最终资产,也不应嵌进运行历史。 + */ + +import type { Generation } from '../../generation' +import type { ActionType } from '../../character' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' + +/** ai/manual 表示运行由哪种交互方式推进,不改变后端数据契约。 */ +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] + +/** Run 的用户目标,也是选择步骤模板和校验资产引用的判别字段。 */ +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] + +/** 从两套步骤模板自动推导,避免类型与运行顺序手工维护两份。 */ +export type WorkflowStepType = + (typeof WORKFLOW_STEP_ORDERS)[keyof typeof WORKFLOW_STEP_ORDERS][number] +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] +export type ExportStatus = (typeof EXPORT_STATUSES)[number] + +/** + * 一次流程步骤的可恢复快照,不包含页面显示状态。 + * + * 此处故意没有通用 input/output:四张角色候选是后端临时文件,如果把 URL 塞入 + * localStorage,候选删除后就会留下无效历史。可恢复信息通过 taskId、正式资产 ID + * 和 referenceStepIds 表达,候选预览数组只存在当前界面/请求缓存中。 + */ +export interface WorkflowStep { + /** 前端步骤快照 ID,用于 Revision 之间引用;它不是后端 task ID。 */ + id: string + /** 步骤业务类型;必须与当前 purpose 对应的模板位置一致。 */ + type: WorkflowStepType + /** 当前步骤在前端编排中的生命周期。 */ + status: WorkflowStepStatus + /** + * 已由后端接受的 Generation ID。步骤 passed/failed 后仍保留, + * 方便历史查询和问题定位;它只是引用,不复制后端生成结果。 + */ + taskId: Generation['id'] | null + /** + * `first-frame` 一次需要 4 个独立生成任务,所以单独保存它们的 ID。 + * 其他步骤必须保持空数组;候选图 URL 仍不进入快照。 + */ + candidateTaskIds: Generation['id'][] + /** + * 请求已发出、但后端 taskId 尚未返回时的本地防重标识。 + * taskId 返回后必须清空,两者不能同时存在。 + */ + submissionId: string | null + /** 失败步骤必须提供原因,其他状态必须为 null。 */ + error: string | null + /** 新版本沿用的历史步骤,用于解释版本来源。 */ + referenceStepIds: string[] +} + +/** + * 一条可回看的任务执行版本。 + * + * 用户目标不变,只是从某个已通过步骤重做时,在同一 Run 下追加 Revision。 + * 网络重试不创建 Revision;用户改成另一个动作目标时则创建新 Run。 + */ +export interface WorkflowRevision { + /** 本版本 ID。 */ + id: string + /** 首版为 null;重做版本指向它沿用的旧 Revision。 */ + basedOnRevisionId: string | null + /** 首版为 null;重做时记录从旧 Revision 的哪个 passed 步骤重开。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 两种任务共享的运行字段。 + * Run 是历史列表的主体;Revision 是 Run 内部的重做记录,不单独伪装成新任务。 + */ +interface WorkflowRunBase { + /** 一次用户任务的稳定 ID,重做时不变。 */ + id: string + /** 所属项目;历史记录和恢复查询均按项目隔离。 */ + projectId: string + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ + currentRevisionId: string + /** 按创建时间排列;历史版本只读,重开时追加新版本。 */ + revisions: WorkflowRevision[] + /** 用户本次任务的目标描述;界面文案不存在这里。 */ + prompt: string | null + /** Run 创建时间,用于历史排序。 */ + createdAt: string + /** 任何可持久业务状态最后更新的时间。 */ + updatedAt: string +} + +/** + * 一次前端创作任务。当前由前端推进并用 localStorage 恢复,不伪装成已有后端持久化。 + * + * create_character 的两个分支表达同一个生命周期:生成中时还没有正式资产 ID; + * 用户从 4 张候选中选择 1 张且后端保存成功后,才同时写入 characterId、 + * outfitId 和 selectedAt。其余 3 张由后端清理,不进入 WorkflowRun。 + * + * add_action 是另一个 Run,只在用户点击“生成动作”时创建, + * 因此必须从开始就绑定已有 characterId 和 outfitId。它会先生成 + * 4 个独立首帧任务,用户选中 1 张后才进入完整动画生成。 + */ +export type WorkflowRun = WorkflowRunBase & + ( + | { + purpose: 'create_character' + characterId: null + outfitId: null + selectedAt: null + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'create_character' + characterId: string + outfitId: string + /** 选中图片已保存为正式角色资产的时间。 */ + selectedAt: string + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + selectedAt?: never + /** Run 创建时就固定的动作资产 ID,保证审核/发布重试幂等。 */ + actionId: string + /** 用户这次要创建的动作名称,刷新后仍用于正式写入资产。 */ + actionName: string + /** 动作的业务语义,不由生成结果反向猜测。 */ + actionType: ActionType + /** 最终动作资产的默认播放帧率。 */ + fps: number + } + ) + +interface CreateWorkflowRunInputBase { + /** 任务所属项目,不允许空字符串。 */ + projectId: string + /** 由 Quick Start 自动推进,或由工作流编辑器手动推进。 */ + driver: WorkflowDriver + /** 用户任务描述;Store 会去掉首尾空白,空文本按 null 保存。 */ + prompt?: string +} + +/** + * 创建 Run 的判别联合输入。 + * + * 创建角色时尚无资产 ID,所以类型明确禁止传入 characterId/outfitId; + * 追加动作必须定位已有角色的具体造型,所以两个 ID 缺一不可。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + actionName: string + actionType: ActionType + fps: number + } + ) diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts new file mode 100644 index 0000000..4b530d3 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -0,0 +1,22 @@ +/** + * WorkflowRun 可执行用例的子目录入口。 + * + * model 只定义数据,store 只管快照,service 负责组合真实 Character/Generation + * 端口完成角色和动作任务。页面应调用这些用例,不自行改写 Run。 + */ + +export { createWorkflowRunService } from './workflow-run-service' +export type { + ActionFirstFrameCandidateBatch, + ActionReviewFrame, + ActionReviewResult, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, + CreateWorkflowRunServiceOptions, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + WorkflowRunService, +} from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts new file mode 100644 index 0000000..af18dc5 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -0,0 +1,373 @@ +/** WorkflowRun Service 的真实用例链测试,不用伪造的页面成功状态代替端口结果。 */ + +import { describe, expect, it, vi } from 'vitest' + +import type { Character, CharacterApis } from '../../character' +import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import { createWorkflowRunStore } from '../store' +import { + createWorkflowRunService, + type CharacterCandidateConfirmationApis, +} from './workflow-run-service' + +function createCharacter(): Character { + return { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: '默认造型', + candidateCharacterTemplates: [], + characterTemplateUrl: 'candidate-2.png', + baseFrames: [], + actions: [], + }, + ], + } +} + +function createGenerationApis() { + const tasks = new Map() + let nextId = 0 + const create = vi.fn(async (input: GenerationInput): Promise => { + const id = `generation-${++nextId}` + const result = + input.type === 'character_template' + ? { + type: 'character_template' as const, + images: [1, 2, 3, 4].map((index) => ({ url: `candidate-${index}.png` })), + } + : input.type === 'first_frame' + ? { type: 'first_frame' as const, image: { url: `first-frame-${id}.png` } } + : { + type: 'complete_animation' as const, + frames: [{ url: 'frame-1.png' }, { url: 'frame-2.png' }], + } + const task: Generation = { + id, + projectId: input.projectId, + type: input.type, + status: 'completed', + result, + error: null, + } + tasks.set(id, task) + return task + }) + const apis: GenerationApis = { + create, + async get(_projectId, id) { + const task = tasks.get(id) + if (!task) throw new Error('任务不存在') + return task + }, + subscribe() { + return () => undefined + }, + } + return { apis, create, tasks } +} + +function createService() { + let id = 0 + let timestamp = 0 + const store = createWorkflowRunStore({ + storage: null, + createId: () => `workflow-id-${++id}`, + now: () => `2026-08-03T00:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + const generation = createGenerationApis() + let character = createCharacter() + const characterApis: CharacterApis = { + get: vi.fn(async () => { + return structuredClone(character) + }), + async listByProject() { + return [structuredClone(character)] + }, + async create() { + return structuredClone(character) + }, + update: vi.fn(async (next: Character) => { + character = structuredClone(next) + return structuredClone(character) + }), + } + const confirmSelection = vi.fn(async () => ({ + character: structuredClone(character), + outfitId: 'outfit-1', + })) + const candidateConfirmationApis: CharacterCandidateConfirmationApis = { confirmSelection } + const service = createWorkflowRunService({ + store, + generationApis: generation.apis, + characterApis, + candidateConfirmationApis, + now: () => `2026-08-03T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + return { service, store, generation, characterApis, confirmSelection } +} + +describe('createWorkflowRunService', () => { + it('runs character selection and action publishing as two linked user tasks', async () => { + const { service, store, generation, characterApis, confirmSelection } = createService() + + const candidates = await service.startCharacter({ + projectId: 'project-1', + prompt: '一位像素风守夜人', + driver: 'ai', + }) + + expect(candidates.candidates).toEqual([ + 'candidate-1.png', + 'candidate-2.png', + 'candidate-3.png', + 'candidate-4.png', + ]) + expect(candidates.run.purpose).toBe('create_character') + expect( + candidates.run.revisions[0]?.steps.find((step) => step.type === 'template-candidate')?.status, + ).toBe('active') + expect(JSON.stringify(store.get(candidates.run.id))).not.toContain('candidate-1.png') + + const characterRun = await service.confirmCharacter({ + runId: candidates.run.id, + selectedImageUrl: 'candidate-2.png', + }) + expect(characterRun).toMatchObject({ + purpose: 'create_character', + status: 'completed', + characterId: 'character-1', + outfitId: 'outfit-1', + }) + expect(confirmSelection).toHaveBeenCalledWith({ + projectId: 'project-1', + generationId: 'generation-1', + selectedImageUrl: 'candidate-2.png', + description: '一位像素风守夜人', + }) + + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '向前行走', + actionType: 'walk', + prompt: '轻快地向前行走', + fps: 12, + driver: 'ai', + }) + + expect(firstFrames.run.id).not.toBe(characterRun.id) + expect(firstFrames.run.purpose).toBe('add_action') + expect(firstFrames.candidates).toEqual([ + 'first-frame-generation-2.png', + 'first-frame-generation-3.png', + 'first-frame-generation-4.png', + 'first-frame-generation-5.png', + ]) + expect( + firstFrames.run.revisions[0]?.steps.find((step) => step.type === 'first-frame-candidate') + ?.status, + ).toBe('active') + expect(JSON.stringify(store.get(firstFrames.run.id))).not.toContain('first-frame-generation') + expect(generation.create).toHaveBeenCalledTimes(5) + + const actionRun = await service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: firstFrames.candidates[1]!, + }) + expect(actionRun.revisions[0]?.steps.find((step) => step.type === 'review')?.status).toBe( + 'active', + ) + expect(generation.create).toHaveBeenCalledTimes(6) + + const review = await service.getActionReview(actionRun.id) + expect(review).toEqual({ + run: actionRun, + generationId: 'generation-6', + frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], + }) + expect(store.get(actionRun.id)).toEqual(actionRun) + + const published = await service.approveAction(actionRun.id) + expect(published.run.status).toBe('completed') + expect(published.actionId).toBe(actionRun.actionId) + expect(published.character.outfits[0]?.actions[0]).toMatchObject({ + id: actionRun.actionId, + name: '向前行走', + type: 'walk', + fps: 12, + }) + expect(published.character.outfits[0]?.actions[0]?.frames).toHaveLength(2) + expect(characterApis.update).toHaveBeenCalledTimes(1) + }) + + it('rejects a candidate that was not returned by the current generation task', async () => { + const { service, confirmSelection } = createService() + const batch = await service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'manual', + }) + + await expect( + service.confirmCharacter({ runId: batch.run.id, selectedImageUrl: 'foreign.png' }), + ).rejects.toThrow('选中图片不属于当前角色生成任务') + expect(confirmSelection).not.toHaveBeenCalled() + }) + + it('does not complete the character run when backend confirmation fails', async () => { + const fixture = createService() + fixture.confirmSelection.mockRejectedValueOnce(new Error('后端候选确认失败')) + const batch = await fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'ai', + }) + + await expect( + fixture.service.confirmCharacter({ + runId: batch.run.id, + selectedImageUrl: 'candidate-1.png', + }), + ).rejects.toThrow('后端候选确认失败') + expect(fixture.store.get(batch.run.id)?.status).toBe('active') + }) + + it('interrupts an active run and continues it without changing the active step', async () => { + const { service, store } = createService() + const batch = await service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'ai', + }) + const activeStepId = batch.run.revisions[0]?.steps.find((step) => step.status === 'active')?.id + + const interrupted = service.interruptRun(batch.run.id) + expect(interrupted.status).toBe('interrupted') + expect(interrupted.revisions[0]?.steps.find((step) => step.status === 'active')?.id).toBe( + activeStepId, + ) + expect(store.get(batch.run.id)?.status).toBe('interrupted') + + const resumed = service.continueRun(batch.run.id) + expect(resumed.status).toBe('active') + expect(resumed.revisions[0]?.steps.find((step) => step.status === 'active')?.id).toBe( + activeStepId, + ) + expect(store.get(batch.run.id)?.status).toBe('active') + }) + + it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { + const { service, store, generation } = createService() + const run = store.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '向前行走', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + const restored = structuredClone(run) + const revision = restored.revisions[0]! + revision.steps[0]!.status = 'passed' + revision.steps[1]!.status = 'active' + revision.steps[1]!.candidateTaskIds = ['persisted-first-frame-1', 'persisted-first-frame-2'] + revision.generationStatus = 'in_progress' + store.save(restored) + for (const index of [1, 2]) { + generation.tasks.set(`persisted-first-frame-${index}`, { + id: `persisted-first-frame-${index}`, + projectId: 'project-1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: `restored-first-frame-${index}.png` } }, + error: null, + }) + } + + const resumed = await service.resumeActionFirstFrameCandidates(run.id) + + expect(generation.create).toHaveBeenCalledTimes(2) + expect(resumed.candidates).toHaveLength(4) + expect(resumed.candidates.slice(0, 2)).toEqual([ + 'restored-first-frame-1.png', + 'restored-first-frame-2.png', + ]) + expect(resumed.run.revisions[0]?.steps[2]?.type).toBe('first-frame-candidate') + expect(resumed.run.revisions[0]?.steps[2]?.status).toBe('active') + }) + + it('restores an action already in review without rerunning generation', async () => { + const { service, generation, characterApis } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + const actionRun = await service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: firstFrames.candidates[0]!, + }) + expect(generation.create).toHaveBeenCalledTimes(5) + expect(characterApis.get).toHaveBeenCalledTimes(2) + + const resumed = await service.resumeAction(actionRun.id) + const review = await service.getActionReview(actionRun.id) + + expect(resumed).toEqual(actionRun) + expect(review.frames).toEqual([{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }]) + expect(generation.create).toHaveBeenCalledTimes(5) + expect(characterApis.get).toHaveBeenCalledTimes(2) + }) + + it('does not expose animation frames before the action reaches review', async () => { + const { service } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + + await expect(service.getActionReview(firstFrames.run.id)).rejects.toThrow( + '动作尚未进入可审核状态', + ) + }) + + it('rejects a first-frame image that is not one of the four current candidates', async () => { + const { service, generation } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + + await expect( + service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: 'foreign-first-frame.png', + }), + ).rejects.toThrow('选中图片不属于当前动作首帧任务') + expect(generation.create).toHaveBeenCalledTimes(4) + }) +}) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts new file mode 100644 index 0000000..09c4e42 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -0,0 +1,778 @@ +/** + * WorkflowRun 的可执行前端用例。 + * + * Store 只保存快照,本 Service 才真正组合 Generation/Character 端口完成业务: + * 生成 4 张角色候选、确认 1 张为正式角色、创建独立动作 Run、 + * 生成 4 张动作首帧候选、根据选中首帧生成完整动画, + * 并在审核后写入角色资产。 + */ + +import type { Action, ActionType, Character, CharacterApis, Frame } from '../../character' +import type { + CharacterTemplateGenerationResult, + CompleteAnimationGenerationResult, + Generation, + GenerationApis, + GenerationEvent, +} from '../../generation' +import type { MediaReference } from '../../media' +import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' +import type { WorkflowRevision, WorkflowRun, WorkflowStep, WorkflowStepType } from '../model' +import type { WorkflowRunStore } from '../store' + +/** + * 确认角色候选的后端原子操作。 + * + * 后端必须在同一用例中保存选中图、返回正式角色/造型 ID, + * 并安排清理同一 generationId 下的其余 3 张候选。 + * 前端不能用“先创建角色、再单独删图”的两步请求伪装原子性。 + */ +export interface CharacterCandidateConfirmationApis { + confirmSelection(input: { + projectId: string + generationId: string + selectedImageUrl: string + description: string + }): Promise<{ character: Character; outfitId: string }> +} + +export interface StartCharacterRunInput { + projectId: string + prompt: string + driver: 'ai' | 'manual' + referenceMedia?: readonly MediaReference[] +} + +export interface CharacterCandidateBatch { + run: WorkflowRun + generationId: string + /** 仅供当前选择界面使用,不写入 WorkflowRun/localStorage。 */ + candidates: readonly string[] +} + +export interface ConfirmCharacterSelectionInput { + runId: string + selectedImageUrl: string +} + +export interface StartActionRunInput { + projectId: string + characterId: string + outfitId: string + actionName: string + actionType: ActionType + prompt?: string | null + fps: number + driver: 'ai' | 'manual' +} + +export interface ActionFirstFrameCandidateBatch { + run: WorkflowRun + /** 4 张图分别对应 4 个后端 Generation,顺序与 candidateTaskIds 一致。 */ + candidateTaskIds: readonly string[] + /** 仅供当前首帧选择界面使用,不写入 WorkflowRun/localStorage。 */ + candidates: readonly string[] +} + +export interface ConfirmActionFirstFrameInput { + runId: string + selectedImageUrl: string +} + +/** + * 动作审核页真正需要的一帧。 + * + * 这里不直接把 Generation 的 DTO 暴露给页面:Generation 负责描述后端任务结果, + * WorkflowRun 则只交付当前任务已经确认可用于审核的图片地址。 + */ +export interface ActionReviewFrame { + imageUrl: string +} + +/** + * 完整动画生成结束后的只读审核结果。 + * + * generationId 让调用方能够定位本次完整动画任务;frames 的数组顺序就是播放顺序。 + * 读取该结果不会修改 Run,也不会把临时图片 URL 写进 WorkflowRun 快照。 + */ +export interface ActionReviewResult { + /** 审核结果只可能属于动作任务,调用方无需再次判断 purpose。 */ + run: Extract + generationId: string + frames: readonly ActionReviewFrame[] +} + +export interface PublishActionResult { + run: WorkflowRun + character: Character + characterId: string + outfitId: string + actionId: string +} + +export interface WorkflowRunService { + /** 暂停进行中的 Run;当前 Revision 和 active 步骤保持不变。 */ + interruptRun(runId: string): WorkflowRun + /** 将已暂停的 Run 恢复为可执行状态;对 active Run 幂等。 */ + continueRun(runId: string): WorkflowRun + startCharacter(input: StartCharacterRunInput): Promise + resumeCharacterCandidates(runId: string): Promise + confirmCharacter(input: ConfirmCharacterSelectionInput): Promise + startAction(input: StartActionRunInput): Promise + resumeActionFirstFrameCandidates(runId: string): Promise + confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise + resumeAction(runId: string): Promise + getActionReview(runId: string): Promise + approveAction(runId: string): Promise +} + +export interface CreateWorkflowRunServiceOptions { + store: WorkflowRunStore + generationApis: GenerationApis + characterApis: CharacterApis + candidateConfirmationApis: CharacterCandidateConfirmationApis + now?: () => string +} + +export function createWorkflowRunService({ + store, + generationApis, + characterApis, + candidateConfirmationApis, + now = () => new Date().toISOString(), +}: CreateWorkflowRunServiceOptions): WorkflowRunService { + function interruptRun(runId: string): WorkflowRun { + const run = requireRun(store, runId) + if (run.status === 'interrupted') return run + if (run.status !== 'active') throw new Error('只有进行中的 WorkflowRun 可以中断') + + const interrupted: WorkflowRun = { + ...run, + status: 'interrupted', + updatedAt: now(), + } + store.save(interrupted) + return interrupted + } + + function continueRun(runId: string): WorkflowRun { + const run = requireRun(store, runId) + if (run.status === 'active') return run + if (run.status !== 'interrupted') throw new Error('只有已中断的 WorkflowRun 可以继续') + + const active: WorkflowRun = { + ...run, + status: 'active', + updatedAt: now(), + } + store.save(active) + return active + } + + async function startCharacter(input: StartCharacterRunInput): Promise { + const prompt = input.prompt.trim() + if (!prompt) throw new Error('请先描述想要创建的角色') + + let run = store.create({ + projectId: input.projectId, + purpose: 'create_character', + driver: input.driver, + prompt, + }) + run = advanceStep(run, 'character-setup', 'character-template', now()) + store.save(run) + + try { + const generation = await generationApis.create({ + type: 'character_template', + projectId: run.projectId, + prompt, + referenceMedia: input.referenceMedia ?? [], + }) + run = recordTask(run, 'character-template', generation.id, now()) + store.save(run) + const terminal = await waitForTerminal(generationApis, generation) + const result = requireCharacterCandidates(terminal) + run = completeGenerationStep( + requireRun(store, run.id), + 'character-template', + 'template-candidate', + now(), + ) + store.save(run) + return toCandidateBatch(run, terminal.id, result) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '角色候选生成失败'), now()) + throw asError(cause) + } + } + + async function resumeCharacterCandidates(runId: string): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'create_character') throw new Error('该 WorkflowRun 不是角色生成任务') + const templateStep = requireStep(run, 'character-template') + if (!templateStep.taskId) throw new Error('角色生成任务 ID 不存在,无法恢复候选') + + const terminal = await waitForTerminal( + generationApis, + await generationApis.get(run.projectId, templateStep.taskId), + ) + const result = requireCharacterCandidates(terminal) + if (templateStep.status === 'active') { + run = completeGenerationStep(run, 'character-template', 'template-candidate', now()) + store.save(run) + } + return toCandidateBatch(run, terminal.id, result) + } + + async function confirmCharacter(input: ConfirmCharacterSelectionInput): Promise { + const batch = await resumeCharacterCandidates(input.runId) + if (!batch.candidates.includes(input.selectedImageUrl)) { + throw new Error('选中图片不属于当前角色生成任务') + } + const run = batch.run + if (run.status !== 'active' || requireStep(run, 'template-candidate').status !== 'active') { + throw new Error('当前 WorkflowRun 不在候选确认阶段') + } + + const confirmed = await candidateConfirmationApis.confirmSelection({ + projectId: run.projectId, + generationId: batch.generationId, + selectedImageUrl: input.selectedImageUrl, + description: run.prompt ?? '', + }) + const outfit = confirmed.character.outfits.find((item) => item.id === confirmed.outfitId) + if ( + confirmed.character.projectId !== run.projectId || + !confirmed.character.id.trim() || + !outfit || + outfit.characterId !== confirmed.character.id + ) { + throw new Error('候选确认接口没有返回有效的角色与造型') + } + + const selectedAt = now() + const completed = editCurrentRevision(run, selectedAt, (revision) => { + const candidate = revision.steps.find((step) => step.type === 'template-candidate')! + candidate.status = 'passed' + revision.status = 'completed' + revision.generationStatus = 'completed' + }) as WorkflowRun + if (completed.purpose !== 'create_character') { + throw new Error('角色确认过程中 WorkflowRun 目的发生了变化') + } + const result: WorkflowRun = { + ...completed, + purpose: 'create_character', + status: 'completed', + characterId: confirmed.character.id, + outfitId: confirmed.outfitId, + selectedAt, + updatedAt: selectedAt, + } + store.save(result) + return result + } + + async function startAction(input: StartActionRunInput): Promise { + if (!input.actionName.trim()) throw new Error('请先填写动作名称') + if (!Number.isFinite(input.fps) || input.fps <= 0) throw new Error('FPS 必须大于 0') + + // 在创建 Run 前校验正式角色,避免错误 ID 留下永远无法继续的空历史。 + const characterImageUrl = await loadCharacterImage( + input.projectId, + input.characterId, + input.outfitId, + ) + + let run = store.create({ + projectId: input.projectId, + purpose: 'add_action', + driver: input.driver, + prompt: input.prompt?.trim() || undefined, + characterId: input.characterId, + outfitId: input.outfitId, + actionName: input.actionName.trim(), + actionType: input.actionType, + fps: input.fps, + }) + run = advanceStep(run, 'action-setup', 'first-frame', now()) + store.save(run) + + try { + return await collectActionFirstFrameCandidates(run.id, characterImageUrl) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选生成失败'), now()) + throw asError(cause) + } + } + + async function resumeActionFirstFrameCandidates( + runId: string, + ): Promise { + const run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active') throw new Error('动作任务已经结束,无法恢复首帧候选') + const characterImageUrl = await loadCharacterImage(run.projectId, run.characterId, run.outfitId) + try { + return await collectActionFirstFrameCandidates(run.id, characterImageUrl) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选恢复失败'), now()) + throw asError(cause) + } + } + + async function loadCharacterImage( + projectId: string, + characterId: string, + outfitId: string, + ): Promise { + const character = await characterApis.get(characterId) + if (character.projectId !== projectId) throw new Error('动作角色不属于当前项目') + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit || outfit.characterId !== characterId) throw new Error('动作所属角色造型不存在') + if (!outfit.characterTemplateUrl) throw new Error('正式角色造型没有可用的角色图') + return outfit.characterTemplateUrl + } + + async function collectActionFirstFrameCandidates( + runId: string, + characterImageUrl: string, + ): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + let firstFrameStep = requireStep(run, 'first-frame') + + if (firstFrameStep.status === 'active') { + while (firstFrameStep.candidateTaskIds.length < ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + if (run.purpose !== 'add_action') { + throw new Error('动作首帧生成过程中 WorkflowRun 目的发生了变化') + } + const task = await generationApis.create({ + type: 'first_frame', + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + actionType: run.actionType, + prompt: run.prompt, + referenceMedia: [characterImageUrl as MediaReference], + }) + run = appendCandidateTask(run, 'first-frame', task.id, now()) + store.save(run) + firstFrameStep = requireStep(run, 'first-frame') + } + } else if ( + firstFrameStep.status !== 'passed' || + requireStep(run, 'first-frame-candidate').status !== 'active' + ) { + throw new Error('当前 WorkflowRun 不在动作首帧选择阶段') + } + + const taskIds = requireStep(run, 'first-frame').candidateTaskIds + const terminals = await Promise.all( + taskIds.map(async (taskId) => + waitForTerminal(generationApis, await generationApis.get(run.projectId, taskId)), + ), + ) + const candidates = terminals.map(requireFirstFrame) + if (firstFrameStep.status === 'active') { + run = completeGenerationStep(run, 'first-frame', 'first-frame-candidate', now()) + store.save(run) + } + return { run, candidateTaskIds: taskIds, candidates } + } + + async function confirmActionFirstFrame( + input: ConfirmActionFirstFrameInput, + ): Promise { + const batch = await resumeActionFirstFrameCandidates(input.runId) + if (!batch.candidates.includes(input.selectedImageUrl)) { + throw new Error('选中图片不属于当前动作首帧任务') + } + const run = batch.run + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + + try { + const animationTask = await generationApis.create({ + type: 'complete_animation', + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + actionType: run.actionType, + firstFrameUrl: input.selectedImageUrl, + prompt: run.prompt, + referenceMedia: [], + }) + const generating = startAnimationFromCandidate(run, animationTask.id, now()) + store.save(generating) + const terminal = await waitForTerminal(generationApis, animationTask) + requireAnimation(terminal) + const completed = completeGenerationStep( + requireRun(store, run.id), + 'complete-animation', + 'review', + now(), + ) + store.save(completed) + return completed + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '完整动画生成失败'), now()) + throw asError(cause) + } + } + + async function resumeAction(runId: string): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active' || requireStep(run, 'review').status === 'active') return run + const animationStep = requireStep(run, 'complete-animation') + if (animationStep.status !== 'active') return run + if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在,无法恢复') + + try { + const terminal = await waitForTerminal( + generationApis, + await generationApis.get(run.projectId, animationStep.taskId), + ) + requireAnimation(terminal) + run = completeGenerationStep(run, 'complete-animation', 'review', now()) + store.save(run) + return run + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '完整动画恢复失败'), now()) + throw asError(cause) + } + } + + async function getActionReview(runId: string): Promise { + const run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active' || requireStep(run, 'review').status !== 'active') { + throw new Error('动作尚未进入可审核状态') + } + const animationStep = requireStep(run, 'complete-animation') + if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在') + const animation = requireAnimation( + await generationApis.get(run.projectId, animationStep.taskId), + ) + + return { + run, + generationId: animationStep.taskId, + frames: animation.frames.map((frame) => ({ imageUrl: frame.url })), + } + } + + async function approveAction(runId: string): Promise { + // 审核页展示和最终写入角色必须读取同一份、经过同一套校验的动画结果。 + // 这样可以避免页面看见一组帧,点击通过后却导入另一组帧。 + const review = await getActionReview(runId) + const run = review.run + + const character = await characterApis.get(run.characterId) + const outfit = character.outfits.find((item) => item.id === run.outfitId) + if (!outfit) throw new Error('动作所属造型不存在') + const action: Action = { + id: run.actionId, + outfitId: outfit.id, + name: run.actionName, + kind: 'custom', + type: run.actionType, + fps: run.fps, + keyFrameIndex: null, + frames: review.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + durationMs: null, + rootMotion: null, + })), + } + const saved = await characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfit.id + ? { + ...item, + actions: [...item.actions.filter((existing) => existing.id !== run.actionId), action], + } + : item, + ), + }) + + const completedAt = now() + const completed = editCurrentRevision(run, completedAt, (revision) => { + requireRevisionStep(revision, 'review').status = 'passed' + requireRevisionStep(revision, 'export').status = 'passed' + revision.status = 'completed' + revision.exportStatus = 'exported' + }) as WorkflowRun + const result: WorkflowRun = { + ...completed, + status: 'completed', + updatedAt: completedAt, + } + store.save(result) + return { + run: result, + character: saved, + characterId: run.characterId, + outfitId: run.outfitId, + actionId: run.actionId, + } + } + + return { + interruptRun, + continueRun, + startCharacter, + resumeCharacterCandidates, + confirmCharacter, + startAction, + resumeActionFirstFrameCandidates, + confirmActionFirstFrame, + resumeAction, + getActionReview, + approveAction, + } +} + +function requireRun(store: WorkflowRunStore, runId: string): WorkflowRun { + const run = store.get(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') + return revision +} + +function requireRevisionStep(revision: WorkflowRevision, type: WorkflowStepType): WorkflowStep { + const step = revision.steps.find((item) => item.type === type) + if (!step) throw new Error(`WorkflowRun 缺少 ${type} 步骤`) + return step +} + +function requireStep(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + return requireRevisionStep(currentRevision(run), type) +} + +function editCurrentRevision( + run: WorkflowRun, + updatedAt: string, + edit: (revision: WorkflowRevision) => void, +): WorkflowRun { + const next = structuredClone(run) + edit(currentRevision(next)) + next.updatedAt = updatedAt + return next +} + +function advanceStep( + run: WorkflowRun, + currentType: WorkflowStepType, + nextType: WorkflowStepType, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const current = requireRevisionStep(revision, currentType) + const next = requireRevisionStep(revision, nextType) + if (current.status !== 'active' || next.status !== 'locked') { + throw new Error(`不能从 ${currentType} 推进到 ${nextType}`) + } + current.status = 'passed' + next.status = 'active' + }) +} + +function recordTask( + run: WorkflowRun, + type: WorkflowStepType, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const step = requireRevisionStep(revision, type) + if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) + step.taskId = taskId + step.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +/** + * 每次后端成功返回一个首帧任务 ID 就立即保存。 + * 如果第 3 个请求时页面刷新,恢复后只需补齐缺少的任务, + * 不会重复提交前两个。 + */ +function appendCandidateTask( + run: WorkflowRun, + type: WorkflowStepType, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const step = requireRevisionStep(revision, type) + if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) + if (step.candidateTaskIds.includes(taskId)) throw new Error('首帧候选任务 ID 重复') + if (step.candidateTaskIds.length >= ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + throw new Error('首帧候选任务数量已达上限') + } + step.candidateTaskIds.push(taskId) + step.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +/** 选中首帧后,把候选步骤和完整动画 taskId 一次写入同一份快照。 */ +function startAnimationFromCandidate( + run: WorkflowRun, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const candidate = requireRevisionStep(revision, 'first-frame-candidate') + const animation = requireRevisionStep(revision, 'complete-animation') + if (candidate.status !== 'active' || animation.status !== 'locked') { + throw new Error('当前 WorkflowRun 不能从首帧候选进入完整动画') + } + candidate.status = 'passed' + animation.status = 'active' + animation.taskId = taskId + animation.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +function completeGenerationStep( + run: WorkflowRun, + currentType: WorkflowStepType, + nextType: WorkflowStepType, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const current = requireRevisionStep(revision, currentType) + const next = requireRevisionStep(revision, nextType) + const hasGenerationTasks = + current.taskId !== null || + (current.type === 'first-frame' && + current.candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) + if (current.status !== 'active' || !hasGenerationTasks || next.status !== 'locked') { + throw new Error(`${currentType} 步骤没有可完成的生成任务`) + } + current.status = 'passed' + next.status = 'active' + revision.generationStatus = nextType === 'complete-animation' ? 'in_progress' : 'completed' + }) +} + +function failActiveRun( + store: WorkflowRunStore, + runId: string, + message: string, + updatedAt: string, +): void { + const existing = store.get(runId) + if (!existing || existing.status !== 'active') return + const failed = editCurrentRevision(existing, updatedAt, (revision) => { + const active = revision.steps.find((step) => step.status === 'active') + if (active) { + active.status = 'failed' + active.error = message + active.submissionId = null + } + revision.status = 'failed' + revision.generationStatus = 'failed' + }) + failed.status = 'failed' + store.save(failed) +} + +function requireCharacterCandidates(generation: Generation): CharacterTemplateGenerationResult { + if (generation.status === 'failed') throw new Error(generation.error || '角色候选生成失败') + if ( + generation.type !== 'character_template' || + generation.status !== 'completed' || + generation.result?.type !== 'character_template' || + generation.result.images.length !== CHARACTER_CANDIDATE_COUNT || + generation.result.images.some((image) => !image.url) + ) { + throw new Error(`角色生成必须返回 ${CHARACTER_CANDIDATE_COUNT} 张有效候选图`) + } + return generation.result +} + +function requireAnimation(generation: Generation): CompleteAnimationGenerationResult { + if (generation.status === 'failed') throw new Error(generation.error || '完整动画生成失败') + if ( + generation.type !== 'complete_animation' || + generation.status !== 'completed' || + generation.result?.type !== 'complete_animation' || + generation.result.frames.length === 0 || + generation.result.frames.some((frame) => !frame.url) + ) { + throw new Error('完整动画任务没有返回有效帧') + } + return generation.result +} + +function requireFirstFrame(generation: Generation): string { + if (generation.status === 'failed') throw new Error(generation.error || '首帧生成失败') + if ( + generation.type !== 'first_frame' || + generation.status !== 'completed' || + generation.result?.type !== 'first_frame' || + !generation.result.image.url + ) { + throw new Error('首帧生成未返回有效图片') + } + return generation.result.image.url +} + +function toCandidateBatch( + run: WorkflowRun, + generationId: string, + result: CharacterTemplateGenerationResult, +): CharacterCandidateBatch { + return { run, generationId, candidates: result.images.map((image) => image.url) } +} + +function waitForTerminal( + generationApis: GenerationApis, + generation: Generation, +): Promise { + if (generation.status === 'completed' || generation.status === 'failed') { + return Promise.resolve(generation) + } + return new Promise((resolve, reject) => { + let stop: () => void = () => undefined + let settledBeforeSubscription = false + const settle = (event: GenerationEvent) => { + if (event.status !== 'completed' && event.status !== 'failed') return + settledBeforeSubscription = true + stop() + resolve({ + id: event.taskId, + projectId: generation.projectId, + type: event.type, + status: event.status, + result: event.result, + error: event.error, + }) + } + try { + stop = generationApis.subscribe(generation.projectId, generation.id, settle) + if (settledBeforeSubscription) stop() + } catch (cause) { + reject(asError(cause)) + } + }) +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function asError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/frontend/src/entities/workflow-run/store/index.ts b/frontend/src/entities/workflow-run/store/index.ts new file mode 100644 index 0000000..75ca223 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/index.ts @@ -0,0 +1,14 @@ +/** + * WorkflowRun 本地仓库的子目录入口。 + * + * 本目录回答“WorkflowRun 在当前前端怎样创建、校验、保存和通知”。 + * 它依赖 model,但 model 不反向依赖 Store。后续接入服务器持久化时, + * 可替换这层的适配实现,不需改变 WorkflowRun 领域类型。 + */ + +export { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './workflow-run-store' diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts new file mode 100644 index 0000000..d1c53f5 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -0,0 +1,439 @@ +/** + * WorkflowRun Store 的可执行业务规则。 + * + * 这些测试不是在验证页面点击,而是锁定数据层不得破坏的契约: + * 角色/动作任务的步骤必须分开,完成角色任务前必须有正式资产, + * 临时候选不得进入持久化快照,Revision 历史引用不得悬空。 + */ + +import { describe, expect, it, vi } from 'vitest' + +import type { WorkflowRevision, WorkflowRun, WorkflowRunPurpose, WorkflowStep } from '../model' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +import { CHARACTER_CANDIDATE_COUNT, WORKFLOW_STEP_ORDERS } from '../model/constants' + +/** 最小 localStorage 替身:既可观察序列化结果,也可主动模拟浏览器存储失败。 */ +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem() { + return this.value + } + + setItem(_key: string, value: string) { + if (this.failOnSet) throw new Error('storage full') + this.value = value + } +} + +/** 根据 purpose 生成测试快照,避免测试自己重复写一套容易过期的步骤顺序。 */ +function createSteps( + prefix: string, + purpose: WorkflowRunPurpose = 'create_character', + activeIndex = 0, +): WorkflowStep[] { + return WORKFLOW_STEP_ORDERS[purpose].map((type, index) => ({ + id: `${prefix}:${type}`, + type, + status: index === activeIndex ? 'active' : 'locked', + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })) +} + +function createRevision( + id = 'revision-1', + purpose: WorkflowRunPurpose = 'create_character', +): WorkflowRevision { + return { + id, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(id, purpose), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-08-03T00:00:00.000Z', + } +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + selectedAt: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [createRevision()], + prompt: 'Create a hero', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + } +} + +function createAddActionRun(): WorkflowRun { + const base = createRun('run-add-action') + return { + id: base.id, + projectId: base.projectId, + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + driver: base.driver, + status: base.status, + currentRevisionId: base.currentRevisionId, + revisions: [createRevision('revision-1', 'add_action')], + prompt: 'Walk forward', + createdAt: base.createdAt, + updatedAt: base.updatedAt, + } +} + +/** 构造“从已通过步骤重做”的两版本历史,用于校验来源链。 */ +function createRunWithHistory(): WorkflowRun { + const first = createRevision() + first.status = 'abandoned' + first.steps = first.steps.map((step, index) => ({ + ...step, + status: index === 0 ? 'passed' : 'locked', + })) + const second = createRevision('revision-2') + second.basedOnRevisionId = first.id + second.restartStepId = first.steps[0]!.id + second.steps[0]!.referenceStepIds = [first.steps[0]!.id] + + return { + ...createRun(), + currentRevisionId: second.id, + revisions: [first, second], + } +} + +describe('createWorkflowRunStore', () => { + // 创建契约:同一界面可连续完成两任务,但底层必须创建两种不同步骤模板的 Run。 + it('creates a character task with only the character steps', () => { + const ids = ['run-1', 'revision-1', 'step-1', 'step-2', 'step-3'] + const store = createWorkflowRunStore({ + storage: null, + createId: () => ids.shift()!, + now: () => '2026-08-03T01:00:00.000Z', + }) + + const run = store.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' Create a hero ', + }) + + expect(CHARACTER_CANDIDATE_COUNT).toBe(4) + expect(run).toMatchObject({ + id: 'run-1', + purpose: 'create_character', + characterId: null, + outfitId: null, + selectedAt: null, + prompt: 'Create a hero', + createdAt: '2026-08-03T01:00:00.000Z', + updatedAt: '2026-08-03T01:00:00.000Z', + }) + expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( + WORKFLOW_STEP_ORDERS.create_character, + ) + expect(run.revisions[0]?.steps.map((step) => step.status)).toEqual([ + 'active', + 'locked', + 'locked', + ]) + }) + + it('creates an action task only from an existing character and outfit', () => { + const ids = [ + 'run-2', + 'revision-2', + 'step-1', + 'step-2', + 'step-3', + 'step-4', + 'step-5', + 'step-6', + 'action-1', + ] + const store = createWorkflowRunStore({ + storage: null, + createId: () => ids.shift()!, + now: () => '2026-08-03T02:00:00.000Z', + }) + + const run = store.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'manual', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + prompt: 'Walk forward', + }) + + expect(run).toMatchObject({ + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + }) + expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( + WORKFLOW_STEP_ORDERS.add_action, + ) + }) + + // 快照所有权契约:保存后修改原对象或查询结果,都不能绕过 Store 改写内存。 + it('persists versioned snapshots and returns defensive copies', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const run = createRun() + + store.save(run) + run.prompt = 'changed outside' + const restored = store.get(run.id)! + restored.revisions[0]!.steps[0]!.status = 'failed' + + expect(store.get(run.id)?.prompt).toBe('Create a hero') + expect(store.get(run.id)?.revisions[0]?.steps[0]?.status).toBe('active') + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates a valid revision history and exposes it through list', () => { + const run = createRunWithHistory() + const storage = new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), + ) + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + expect(store.list()).toEqual([run]) + }) + + // 恢复边界采用严格白名单:坏 JSON、未知版本和断裂历史链都不得进入内存。 + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + [ + 'missing history source', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { ...createRunWithHistory().revisions[1], basedOnRevisionId: 'missing' }, + ], + }, + ], + }), + ], + [ + 'unknown referenced step', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { + ...createRunWithHistory().revisions[1], + steps: createRunWithHistory().revisions[1]!.steps.map((step, index) => + index === 0 ? { ...step, referenceStepIds: ['missing-step'] } : step, + ), + }, + ], + }, + ], + }), + ], + ])('ignores %s during hydration', (_label, serialized) => { + expect(createWorkflowRunStore({ storage: new TestStorage(serialized) }).list()).toEqual([]) + }) + + it('rejects invalid snapshots before they reach memory', () => { + const store = createWorkflowRunStore({ storage: null }) + const invalid = createRun() + invalid.revisions[0]!.steps[0]!.error = 'failed without failed status' + + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + expect(store.get(invalid.id)).toBeNull() + }) + + // 动作不是游离资产;它必须同时定位角色和具体造型。 + it('requires character and outfit references when adding an action', () => { + const valid = createAddActionRun() + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const invalid = { + ...valid, + characterId: null, + outfitId: null, + } as unknown as WorkflowRun + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + + const hydrated = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), + ), + }) + expect(hydrated.get(invalid.id)).toBeNull() + }) + + // 用户点选候选并不等于任务完成;必须等正式资产保存成功后再原子性填入三个字段。 + it('requires a saved asset and selection time before completing character creation', () => { + const valid = createRun() + valid.status = 'completed' + valid.characterId = 'character-1' + valid.outfitId = 'outfit-1' + valid.selectedAt = '2026-08-03T03:00:00.000Z' + valid.revisions[0]!.status = 'completed' + valid.revisions[0]!.steps = valid.revisions[0]!.steps.map((step) => ({ + ...step, + status: 'passed', + })) + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const missingSelection = { ...valid, selectedAt: null } as unknown as WorkflowRun + expect(() => store.save(missingSelection)).toThrow('Invalid WorkflowRun snapshot') + }) + + // taskId 是追踪线索,不是仅在加载中存活的 UI 状态。 + it('retains a generation task id after its step passes', () => { + const run = createRun() + run.revisions[0]!.steps[0] = { + ...run.revisions[0]!.steps[0]!, + status: 'passed', + taskId: 'generation-1', + } + run.revisions[0]!.steps[1] = { + ...run.revisions[0]!.steps[1]!, + status: 'active', + } + const store = createWorkflowRunStore({ storage: null }) + + store.save(run) + + expect(store.get(run.id)?.revisions[0]?.steps[0]?.taskId).toBe('generation-1') + }) + + it('requires exactly four task ids before the first-frame batch can pass', () => { + const run = createAddActionRun() + const steps = run.revisions[0]!.steps + steps[0]!.status = 'passed' + steps[1]!.status = 'passed' + steps[1]!.candidateTaskIds = ['first-1', 'first-2', 'first-3'] + steps[2]!.status = 'active' + const store = createWorkflowRunStore({ storage: null }) + + expect(() => store.save(run)).toThrow('Invalid WorkflowRun snapshot') + + steps[1]!.candidateTaskIds.push('first-4') + store.save(run) + expect(store.get(run.id)?.revisions[0]?.steps[1]?.candidateTaskIds).toHaveLength(4) + }) + + // 四张候选属于临时缓存;运行历史只记录生成 taskId 和最终正式资产引用。 + it('rejects temporary candidate payloads in persisted workflow steps', () => { + const run = createRun() + const withCandidates = { + ...run, + revisions: [ + { + ...run.revisions[0], + steps: run.revisions[0]!.steps.map((step, index) => + index === 1 + ? { + ...step, + output: { + candidates: ['temporary-1', 'temporary-2', 'temporary-3', 'temporary-4'], + }, + } + : step, + ), + }, + ], + } as unknown as WorkflowRun + const store = createWorkflowRunStore({ storage: null }) + + expect(() => store.save(withCandidates)).toThrow('Invalid WorkflowRun snapshot') + }) + + // localStorage 失败不应让当前会话已完成的操作倒退,但刷新恢复能力会降级。 + it('keeps memory authoritative when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + + expect(() => store.save(createRun())).not.toThrow() + expect(store.get('run-1')).toEqual(createRun()) + }) + + it('notifies run and history subscribers without sharing mutable values', () => { + const store = createWorkflowRunStore({ storage: null }) + const runListener = vi.fn((run: WorkflowRun) => { + run.prompt = 'listener mutation' + }) + const listListener = vi.fn() + const unsubscribeRun = store.subscribe('run-1', runListener) + const unsubscribeAll = store.subscribeAll(listListener) + + store.save(createRun()) + + expect(store.get('run-1')?.prompt).toBe('Create a hero') + expect(listListener).toHaveBeenCalledWith([createRun()]) + unsubscribeRun() + unsubscribeAll() + store.save({ ...createRun(), prompt: 'second save' }) + expect(runListener).toHaveBeenCalledTimes(1) + expect(listListener).toHaveBeenCalledTimes(1) + }) + + it('uses the stable browser storage key', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ storage: { getItem: () => null, setItem } }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts new file mode 100644 index 0000000..438be22 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -0,0 +1,465 @@ +/** + * WorkflowRun 的本地仓库与运行时边界校验。 + * + * 这个 Store 只做四件事:创建合法初始快照、保存/读取快照、刷新恢复、通知订阅者。 + * 它不是 WorkflowController:不调 Generation API、不处理 SSE、不决定何时进入下一步, + * 也不负责调用后端候选图清理接口。这些编排行为由同一 Entity 下的 + * WorkflowRun Service 组合已有 Generation/Character 端口完成。 + * + * localStorage 是当前没有 WorkflowRun 后端持久化时的刷新恢复适配器, + * 不代表把浏览器宣布为最终服务器数据源。 + */ + +import type { + CreateWorkflowRunInput, + WorkflowRevision, + WorkflowRun, + WorkflowRunPurpose, +} from '../model' +import { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_STATUSES, +} from '../model/constants' + +/** 稳定 key 保证刷新前后读取同一份数据,不随页面路由或组件名改动。 */ +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' + +/** + * 持久化数据版本。当快照结构或业务不变式变更时递增, + * 防止新代码将旧 JSON 误认为合法运行状态。 + */ +export const WORKFLOW_RUN_STORAGE_VERSION = 3 + +type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void + +/** 只依赖最小存储能力,测试可用内存替身,未来也可换成其他适配器。 */ +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** + * WorkflowRun 的最小仓库接口。 + * + * create 只产生初始合法快照;save 保存已由业务层推进的整体快照。 + * subscribe 服务单个创作页,subscribeAll 服务历史列表;两者都不改写数据。 + */ +export interface WorkflowRunStore { + create(input: CreateWorkflowRunInput): WorkflowRun + get(runId: WorkflowRun['id']): WorkflowRun | null + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** null 表示仅保存在当前内存;未传时浏览器默认使用 localStorage。 */ + storage?: WorkflowRunStorage | null + /** 测试可注入确定性 ID;生产默认使用 crypto.randomUUID。 */ + createId?: () => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +/** + * 校验单个步骤的关键不变式。 + * + * - failed 必须有可读错误,非 failed 不得残留旧错误; + * - submissionId 只能出现在 active 且与 taskId 互斥; + * - taskId 可在 passed/failed 后保留,便于追踪后端任务; + * - 只有 first-frame 可保存最多 4 个 candidateTaskIds,passed 时必须已集齐 4 个; + * - 拒绝 input/output 是为了防止四张临时候选或页面对象被塞进长期快照。 + */ +function isWorkflowStep(value: unknown, expectedType: string): boolean { + if (!isRecord(value)) return false + const candidateTaskIds = isStringArray(value.candidateTaskIds) ? value.candidateTaskIds : null + + const errorIsValid = + isNullableString(value.error) && + (value.status === 'failed' + ? typeof value.error === 'string' && value.error.trim().length > 0 + : value.error === null) + const taskStateIsValid = + isNullableString(value.taskId) && + candidateTaskIds !== null && + new Set(candidateTaskIds).size === candidateTaskIds.length && + candidateTaskIds.every((id) => id.length > 0) && + isNullableString(value.submissionId) && + !(value.taskId !== null && value.submissionId !== null) && + (value.submissionId === null || value.status === 'active') && + (value.taskId === null || ['active', 'passed', 'failed'].includes(String(value.status))) + const candidateTasksAreValid = + candidateTaskIds !== null && + (expectedType === 'first-frame' + ? value.taskId === null && + candidateTaskIds.length <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && + (value.status !== 'passed' || + candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) + : candidateTaskIds.length === 0) + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + value.type === expectedType && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + !('input' in value) && + !('output' in value) && + taskStateIsValid && + candidateTasksAreValid && + errorIsValid && + isStringArray(value.referenceStepIds) + ) +} + +/** + * Revision 必须完整包含当前 purpose 的步骤模板,数量、顺序和 type 都要一致。 + * 这会阻止 add_action 在恢复时被误塞入角色母版步骤,也阻止页面自行改变顺序。 + */ +function isWorkflowRevision( + value: unknown, + purpose: WorkflowRunPurpose, +): value is WorkflowRevision { + if (!isRecord(value) || !Array.isArray(value.steps)) return false + const stepIds = value.steps.map((step) => (isRecord(step) ? step.id : null)) + const expectedOrder = WORKFLOW_STEP_ORDERS[purpose] + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + value.steps.length === expectedOrder.length && + value.steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) && + new Set(stepIds).size === stepIds.length && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +/** + * 验证 Revision 历史链,防止伪造或悬空引用。 + * + * 首版不能有来源;后续版本必须指向更早的 Revision,且只能从该版本中 + * 已 passed 的步骤重开。referenceStepIds 只能引用已经出现的旧步骤, + * 不能指向未来版本或不存在的 ID。 + */ +function hasValidRevisionLine(revisions: WorkflowRevision[]): boolean { + const prior = new Map() + const priorStepIds = new Set() + + for (const [index, revision] of revisions.entries()) { + if (prior.has(revision.id)) return false + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if (revision.basedOnRevisionId === null || revision.restartStepId === null) return false + const source = prior.get(revision.basedOnRevisionId) + if ( + !source?.steps.some( + (step) => step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + if ( + revision.steps.some((step) => + step.referenceStepIds.some((stepId) => !priorStepIds.has(stepId)), + ) + ) { + return false + } + prior.set(revision.id, revision) + revision.steps.forEach((step) => priorStepIds.add(step.id)) + } + + return true +} + +/** + * 整体 Run 校验。它在两个不可信边界调用:读取 localStorage 和 save() 写入前。 + * 因此 TypeScript 类型正确仍不够;JSON、旧版数据和手工断言都可能绕过编译期。 + */ +function isWorkflowRun(value: unknown): value is WorkflowRun { + if ( + !isRecord(value) || + !isMember(value.purpose, WORKFLOW_PURPOSES) || + !Array.isArray(value.revisions) || + value.revisions.length === 0 + ) { + return false + } + const purpose = value.purpose + if (!value.revisions.every((revision) => isWorkflowRevision(revision, purpose))) return false + + const revisions = value.revisions + const current = revisions.at(-1) + if (!current || current.id !== value.currentRevisionId || !hasValidRevisionLine(revisions)) { + return false + } + + // Run 的结果必须与当前 Revision 结果同步,避免页面各读一层时得到矛盾答案。 + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (current.status !== expectedRevisionStatus) return false + if (revisions.slice(0, -1).some((revision) => revision.status === 'active')) return false + + // 运行中/已中断保留唯一当前步骤;终态不得继续挂着 active 步骤。 + const activeStepCount = current.steps.filter((step) => step.status === 'active').length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + // + // 角色 Run 有两个合法阶段:尚未选择时三个字段都为 null, + // 或正式保存成功后 ID 与选择时间同时存在。动作 Run 则从创建起就必须绑定角色造型。 + const targetIsValid = + value.purpose === 'add_action' + ? isNonEmptyString(value.characterId) && + isNonEmptyString(value.outfitId) && + value.selectedAt === undefined && + isNonEmptyString(value.actionId) && + isNonEmptyString(value.actionName) && + ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType)) && + typeof value.fps === 'number' && + Number.isFinite(value.fps) && + value.fps > 0 + : value.purpose === 'create_character' && + ((value.characterId === null && value.outfitId === null && value.selectedAt === null) || + (isNonEmptyString(value.characterId) && + isNonEmptyString(value.outfitId) && + isNonEmptyString(value.selectedAt))) + + if ( + value.purpose === 'create_character' && + value.status === 'completed' && + value.characterId === null + ) { + return false + } + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNonEmptyString(value.projectId) && + targetIsValid && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + isNullableString(value.prompt) && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.updatedAt) + ) +} + +/** + * 持久化读取采用“失败即忽略”策略:一条损坏数据不能阻止应用启动。 + * 这不是默默修复错误;无法证明合法的 Run 不进入内存,避免错误状态被继续推进。 + */ +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + const value: unknown = JSON.parse(serialized) + if ( + !isRecord(value) || + value.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(value.runs) + ) { + return [] + } + return value.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } catch { + return [] + } +} + +/** SSR/测试环境没有 window,隐私模式也可能拒绝 localStorage,因此存储能力必须可降级。 */ +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage + } catch { + return null + } +} + +/** 运行、版本和步骤都要跨刷新稳定引用,所以不使用数组下标或时间戳充当 ID。 */ +function createRandomId(): string { + if (typeof globalThis.crypto?.randomUUID !== 'function') { + throw new Error('crypto.randomUUID is required to create a WorkflowRun') + } + return globalThis.crypto.randomUUID() +} + +/** + * 创建 WorkflowRun Store。 + * + * 内存快照是当前会话的权威状态,localStorage 仅用于刷新恢复。 + * 所以存储写入失败时不回滚内存:用户当前页面仍可继续工作, + * 但刷新恢复能力已降级。未来接入后端持久化时,应替换适配器而不改变业务模型。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const createId = options.createId ?? createRandomId + const now = options.now ?? (() => new Date().toISOString()) + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + const listListeners = new Set() + + const snapshotList = () => [...runs.values()].map((run) => structuredClone(run)) + + const store: WorkflowRunStore = { + create(input) { + // create 只在用户真正发起任务时调用: + // 选好角色后进入动作区域不创建空 Run,点击“生成动作”才创建 add_action。 + const createdAt = now() + const runId = createId() + const revisionId = createId() + // 初始时只激活第一步,后续步骤等待前置条件通过。 + const steps = WORKFLOW_STEP_ORDERS[input.purpose].map((type, index) => ({ + id: createId(), + type, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })) + const base = { + id: runId, + projectId: input.projectId, + purpose: input.purpose, + driver: input.driver, + status: 'active' as const, + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active' as const, + steps, + generationStatus: 'not_started' as const, + exportStatus: 'not_exported' as const, + createdAt, + }, + ], + prompt: input.prompt?.trim() || null, + createdAt, + updatedAt: createdAt, + } + const run: WorkflowRun = + input.purpose === 'create_character' + ? { ...base, purpose: input.purpose, characterId: null, outfitId: null, selectedAt: null } + : { + ...base, + purpose: input.purpose, + characterId: input.characterId, + outfitId: input.outfitId, + actionId: createId(), + actionName: input.actionName.trim(), + actionType: input.actionType, + fps: input.fps, + } + + // 统一走 save 以复用运行时校验、持久化和订阅通知,避免 create 产生特例状态。 + store.save(run) + return structuredClone(run) + }, + get(runId) { + const run = runs.get(runId) + return run ? structuredClone(run) : null + }, + list: snapshotList, + save(run) { + if (!isWorkflowRun(run)) throw new TypeError('Invalid WorkflowRun snapshot') + // 内外都使用深拷贝,防止调用方在 save/get 后继续修改对象,绕过校验篡改 Store。 + const saved = structuredClone(run) + runs.set(saved.id, saved) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 持久化失败不撤销已经写入的当前会话状态,只降级刷新恢复能力。 + } + + for (const listener of listeners.get(saved.id) ?? []) { + try { + // 每个订阅者获得独立副本,一个页面不能通过修改参数影响另一个页面。 + listener(structuredClone(saved)) + } catch { + // 一个订阅方失败不能阻断其他订阅方。 + } + } + for (const listener of listListeners) { + try { + listener(snapshotList()) + } catch { + // 历史列表订阅方失败不影响已保存状态。 + } + } + }, + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, + } + + return store +} 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 0000000..0c38c4b --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,313 @@ +/** WorkflowController 只测协调边界,WorkflowRun Service 内部流程由 Entity 自己的测试保护。 */ + +import { describe, expect, it, vi } from 'vitest' + +import type { + ActionFirstFrameCandidateBatch, + ActionReviewResult, + CharacterCandidateBatch, + WorkflowRun, + WorkflowRunService, + WorkflowRunStore, + WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from './controller' + +function createRun( + purpose: 'create_character' | 'add_action', + activeType: WorkflowStepType, + status: WorkflowRun['status'] = 'active', +): WorkflowRun { + const base = { + id: `run-${purpose}`, + projectId: 'project-1', + purpose, + driver: 'ai' as const, + status, + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: status === 'completed' ? ('completed' as const) : ('active' as const), + steps: [ + { + id: 'step-1', + type: activeType, + status: + status === 'active' || status === 'interrupted' + ? ('active' as const) + : ('passed' as const), + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + }, + ], + generationStatus: 'not_started' as const, + exportStatus: 'not_exported' as const, + createdAt: '2026-08-03T00:00:00.000Z', + }, + ], + prompt: '角色', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + } + return ( + purpose === 'create_character' + ? { ...base, purpose, characterId: null, outfitId: null, selectedAt: null } + : { + ...base, + purpose, + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + } + ) as WorkflowRun +} + +function createFixture(initialRuns: WorkflowRun[] = []) { + const runs = new Map(initialRuns.map((run) => [run.id, run])) + const store: WorkflowRunStore = { + create: vi.fn(), + get: vi.fn((runId) => runs.get(runId) ?? null), + list: vi.fn(() => [...runs.values()]), + save: vi.fn(), + subscribe: vi.fn(() => () => undefined), + subscribeAll: vi.fn(() => () => undefined), + } + const service = { + startCharacter: vi.fn(), + resumeCharacterCandidates: vi.fn(), + confirmCharacter: vi.fn(), + startAction: vi.fn(), + resumeActionFirstFrameCandidates: vi.fn(), + confirmActionFirstFrame: vi.fn(), + resumeAction: vi.fn(), + getActionReview: vi.fn(), + approveAction: vi.fn(), + interruptRun: vi.fn(), + continueRun: vi.fn(), + } as unknown as WorkflowRunService + return { controller: createWorkflowController({ store, service }), store, service } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +describe('createWorkflowController', () => { + it('delegates business commands to WorkflowRun Service without saving snapshots itself', async () => { + const { controller, store, service } = createFixture() + const reviewing = createRun('add_action', 'review') + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], + } + vi.mocked(service.confirmActionFirstFrame).mockResolvedValue(reviewing) + vi.mocked(service.getActionReview).mockResolvedValue(review) + vi.mocked(service.interruptRun).mockReturnValue(reviewing) + const characterInput = { projectId: 'project-1', prompt: '角色', driver: 'ai' as const } + const actionInput = { + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk' as const, + fps: 12, + driver: 'ai' as const, + } + + await controller.startCharacter(characterInput) + await controller.confirmCharacter({ runId: 'character-run', selectedImageUrl: 'character.png' }) + await controller.startAction(actionInput) + await expect( + controller.confirmActionFirstFrame({ + runId: 'action-run', + selectedImageUrl: 'first-frame.png', + }), + ).resolves.toEqual(review) + await controller.approveAction('action-run') + expect(controller.interrupt(reviewing.id)).toEqual(reviewing) + + expect(service.startCharacter).toHaveBeenCalledWith(characterInput) + expect(service.confirmCharacter).toHaveBeenCalledWith({ + runId: 'character-run', + selectedImageUrl: 'character.png', + }) + expect(service.startAction).toHaveBeenCalledWith(actionInput) + expect(service.confirmActionFirstFrame).toHaveBeenCalledWith({ + runId: 'action-run', + selectedImageUrl: 'first-frame.png', + }) + expect(service.getActionReview).toHaveBeenCalledWith(reviewing.id) + expect(service.approveAction).toHaveBeenCalledWith('action-run') + expect(service.interruptRun).toHaveBeenCalledWith(reviewing.id) + expect(store.save).not.toHaveBeenCalled() + }) + + it('restores character candidates through the character resume use case', async () => { + const run = createRun('create_character', 'character-template') + const batch: CharacterCandidateBatch = { + run, + generationId: 'generation-1', + candidates: ['a.png', 'b.png', 'c.png', 'd.png'], + } + const { controller, service } = createFixture([run]) + vi.mocked(service.resumeCharacterCandidates).mockResolvedValue(batch) + + await expect(controller.resume(run.id)).resolves.toEqual({ + phase: 'character-candidates', + ...batch, + }) + }) + + it('restores action first-frame candidates without starting complete animation', async () => { + const run = createRun('add_action', 'first-frame-candidate') + const batch: ActionFirstFrameCandidateBatch = { + run, + candidateTaskIds: ['first-1', 'first-2', 'first-3', 'first-4'], + candidates: ['a.png', 'b.png', 'c.png', 'd.png'], + } + const { controller, service } = createFixture([run]) + vi.mocked(service.resumeActionFirstFrameCandidates).mockResolvedValue(batch) + + const snapshot = await controller.resume(run.id) + + expect(snapshot).toEqual({ phase: 'action-first-frame-candidates', ...batch }) + expect(service.resumeAction).not.toHaveBeenCalled() + }) + + it('resumes complete animation through Service and returns the review phase', async () => { + const generating = createRun('add_action', 'complete-animation') + const reviewing = createRun('add_action', 'review') + const { controller, service } = createFixture([generating]) + vi.mocked(service.resumeAction).mockResolvedValue(reviewing) + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], + } + vi.mocked(service.getActionReview).mockResolvedValue(review) + + await expect(controller.resume(generating.id)).resolves.toEqual({ + phase: 'action-review', + ...review, + }) + expect(service.getActionReview).toHaveBeenCalledWith(reviewing.id) + }) + + it('restores an existing review with frames instead of returning only the run', async () => { + const reviewing = createRun('add_action', 'review') + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }], + } + const { controller, service } = createFixture([reviewing]) + vi.mocked(service.getActionReview).mockResolvedValue(review) + + await expect(controller.resume(reviewing.id)).resolves.toEqual({ + phase: 'action-review', + ...review, + }) + expect(service.resumeAction).not.toHaveBeenCalled() + }) + + it('returns setup and terminal snapshots without invoking generation recovery', async () => { + const setup = createRun('create_character', 'character-setup') + const completed = createRun('add_action', 'review', 'completed') + const { controller, service } = createFixture([setup, completed]) + + await expect(controller.resume(setup.id)).resolves.toEqual({ + phase: 'character-setup', + run: setup, + }) + await expect(controller.resume(completed.id)).resolves.toEqual({ + phase: 'terminal', + run: completed, + }) + expect(service.resumeCharacterCandidates).not.toHaveBeenCalled() + expect(service.resumeAction).not.toHaveBeenCalled() + }) + + it('continues an interrupted run before restoring its active page', async () => { + const interrupted = createRun('create_character', 'character-setup', 'interrupted') + const active = { ...interrupted, status: 'active' as const } + const { controller, service } = createFixture([interrupted]) + vi.mocked(service.continueRun).mockReturnValue(active) + + await expect(controller.resume(interrupted.id)).resolves.toEqual({ + phase: 'character-setup', + run: active, + }) + expect(service.continueRun).toHaveBeenCalledWith(interrupted.id) + }) + + it('shares one in-flight recovery when the same run is resumed concurrently', async () => { + const run = createRun('add_action', 'first-frame-candidate') + const batch: ActionFirstFrameCandidateBatch = { + run, + candidateTaskIds: ['first-1', 'first-2', 'first-3', 'first-4'], + candidates: ['a.png', 'b.png', 'c.png', 'd.png'], + } + const pending = deferred() + const { controller, service } = createFixture([run]) + vi.mocked(service.resumeActionFirstFrameCandidates).mockReturnValue(pending.promise) + + const first = controller.resume(run.id) + const second = controller.resume(run.id) + expect(service.resumeActionFirstFrameCandidates).toHaveBeenCalledTimes(1) + + pending.resolve(batch) + await expect(Promise.all([first, second])).resolves.toEqual([ + { phase: 'action-first-frame-candidates', ...batch }, + { phase: 'action-first-frame-candidates', ...batch }, + ]) + }) + + it('reads the existing review when confirming a first frame is retried after advancement', async () => { + const reviewing = createRun('add_action', 'review') + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }], + } + const { controller, service } = createFixture([reviewing]) + vi.mocked(service.getActionReview).mockResolvedValue(review) + + await expect( + controller.confirmActionFirstFrame({ + runId: reviewing.id, + selectedImageUrl: 'first-frame.png', + }), + ).resolves.toEqual(review) + expect(service.confirmActionFirstFrame).not.toHaveBeenCalled() + }) + + it('delegates reads, project filtering and subscriptions to Store', () => { + const first = createRun('create_character', 'character-setup') + const second = { ...createRun('add_action', 'action-setup'), projectId: 'project-2' } + const { controller, store } = createFixture([first, second]) + const listener = vi.fn() + const listListener = vi.fn() + + expect(controller.getWorkflow(first.id)).toBe(first) + expect(controller.listWorkflows('project-1')).toEqual([first]) + controller.subscribe(first.id, listener) + controller.subscribeAll(listListener) + + expect(store.subscribe).toHaveBeenCalledWith(first.id, listener) + expect(store.subscribeAll).toHaveBeenCalledWith(listListener) + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 0000000..366702a --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,217 @@ +/** + * 创作页面与 WorkflowRun Entity 之间的协调层。 + * + * WorkflowRun Service 已经负责步骤迁移、Generation 调用和 Character 写入; + * Controller 只把这些用例整理成页面命令,并在刷新时根据当前步骤选择 + * 正确的恢复入口。它不拥有第二份流程状态,也不直接调用 store.save()。 + */ + +import type { + ActionFirstFrameCandidateBatch, + ActionReviewResult, + CharacterCandidateBatch, + ConfirmActionFirstFrameInput, + ConfirmCharacterSelectionInput, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + WorkflowRun, + WorkflowRunService, + WorkflowRunStore, + WorkflowStep, +} from '@/entities' + +/** + * 页面恢复结果。 + * + * 候选阶段携带当次从后端取回的临时 URL;动作审核阶段携带完整动画帧。 + * 页面用 phase 选择界面,无需自己解释步骤顺序或后端任务状态。 + */ +export type WorkflowControllerSnapshot = + | { phase: 'character-setup'; run: WorkflowRun } + | ({ phase: 'character-candidates' } & CharacterCandidateBatch) + | { phase: 'action-setup'; run: WorkflowRun } + | ({ phase: 'action-first-frame-candidates' } & ActionFirstFrameCandidateBatch) + | ({ phase: 'action-review' } & ActionReviewResult) + | { phase: 'terminal'; run: WorkflowRun } + +export interface WorkflowController { + /** 开始角色任务,完成后返回 4 张角色候选。 */ + startCharacter(input: StartCharacterRunInput): Promise + /** 确认角色候选;正式保存成功后角色 Run 完成。 */ + confirmCharacter(input: ConfirmCharacterSelectionInput): Promise + /** 用户点击生成动作时创建独立 Run,返回 4 张动作首帧候选。 */ + startAction(input: StartActionRunInput): Promise + /** 选中 1 张首帧后生成完整动画,并返回审核页可直接播放的有序帧。 */ + confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise + /** 审核通过后写入 Character,返回导入 Playtest 所需的稳定 ID。 */ + approveAction(runId: WorkflowRun['id']): Promise + /** 暂停进行中的 Run;状态变更由 WorkflowRun Service 执行。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun + + /** 按 ID 读取防御性快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + /** 列出全部 Run,可选按项目过滤,供后续历史页使用。 */ + listWorkflows(projectId?: string): readonly WorkflowRun[] + /** 订阅单个 Run;Controller 不额外缓存副本。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + /** 订阅列表变化,供后续项目/历史视图复用。 */ + subscribeAll(listener: (runs: readonly WorkflowRun[]) => void): () => void + + /** + * 页面刷新或路由重进时的唯一恢复入口。 + * Controller 只分流,真正的 taskId 查询、订阅和结果校验由 Service 完成。 + */ + resume(runId: WorkflowRun['id']): Promise +} + +export interface CreateWorkflowControllerOptions { + /** 当前 WorkflowRun 快照的统一读取边界;Controller 只读取和订阅。 */ + store: WorkflowRunStore + /** 作为唯一业务写入入口,Controller 不复制其逻辑。 */ + service: WorkflowRunService +} + +export function createWorkflowController({ + store, + service, +}: CreateWorkflowControllerOptions): WorkflowController { + /** + * React StrictMode、路由重进或多个只读视图可能同时恢复同一个 Run。 + * 共享在途 Promise,避免 Service 为同一首帧阶段重复补建 Generation。 + */ + const pendingResumes = new Map>() + + function getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null { + return store.get(runId) + } + + function listWorkflows(projectId?: string): readonly WorkflowRun[] { + const runs = store.list() + return projectId === undefined ? runs : runs.filter((run) => run.projectId === projectId) + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function subscribeAll(listener: (runs: readonly WorkflowRun[]) => void) { + return store.subscribeAll(listener) + } + + function resume(runId: WorkflowRun['id']): Promise { + const pending = pendingResumes.get(runId) + if (pending) return pending + + const request = restoreWorkflow(runId) + pendingResumes.set(runId, request) + const clear = () => { + if (pendingResumes.get(runId) === request) pendingResumes.delete(runId) + } + void request.then(clear, clear) + return request + } + + async function restoreWorkflow( + runId: WorkflowRun['id'], + ): Promise { + let run = store.get(runId) + if (!run) return null + if (run.status === 'interrupted') run = service.continueRun(run.id) + if (run.status !== 'active') return { phase: 'terminal', run } + + const activeStep = getActiveStep(run) + if (run.purpose === 'create_character') { + if (activeStep.type === 'character-setup') return { phase: 'character-setup', run } + if (activeStep.type !== 'character-template' && activeStep.type !== 'template-candidate') { + throw new Error(`角色 WorkflowRun 无法恢复未知步骤:${activeStep.type}`) + } + const batch = await service.resumeCharacterCandidates(run.id) + return { phase: 'character-candidates', ...batch } + } + + if (activeStep.type === 'action-setup') return { phase: 'action-setup', run } + if (activeStep.type === 'first-frame' || activeStep.type === 'first-frame-candidate') { + const batch = await service.resumeActionFirstFrameCandidates(run.id) + return { phase: 'action-first-frame-candidates', ...batch } + } + if (activeStep.type === 'complete-animation') { + return toActionSnapshot(await service.resumeAction(run.id), service) + } + if (activeStep.type === 'review') { + const review = await service.getActionReview(run.id) + return { phase: 'action-review', ...review } + } + throw new Error(`动作 WorkflowRun 无法恢复未知步骤:${activeStep.type}`) + } + + async function confirmActionFirstFrame( + input: ConfirmActionFirstFrameInput, + ): Promise { + const existing = store.get(input.runId) + if (existing?.purpose === 'add_action' && existing.status === 'active') { + const activeStep = getActiveStep(existing) + if (activeStep.type === 'complete-animation' || activeStep.type === 'review') { + return readActionReview(existing, service) + } + } + + // Service 先完成状态推进,再通过只读用例返回同一任务的审核帧。 + // Controller 不查询 Generation,也不把帧 URL 塞进 WorkflowRun Store。 + const run = await service.confirmActionFirstFrame(input) + return service.getActionReview(run.id) + } + + return { + startCharacter: (input) => service.startCharacter(input), + confirmCharacter: (input) => service.confirmCharacter(input), + startAction: (input) => service.startAction(input), + confirmActionFirstFrame, + approveAction: (runId) => service.approveAction(runId), + interrupt: (runId) => service.interruptRun(runId), + getWorkflow, + listWorkflows, + subscribe, + subscribeAll, + resume, + } +} + +/** + * 首帧确认已经把 Run 推进到动画或审核阶段时,重试只能读取既有结果。 + * 再次调用确认用例会重复消费旧候选,并把已经成功推进的任务误报为失败。 + */ +async function readActionReview( + run: Extract, + service: WorkflowRunService, +): Promise { + let reviewing: WorkflowRun = run + if (getActiveStep(reviewing).type === 'complete-animation') { + reviewing = await service.resumeAction(reviewing.id) + } + if (reviewing.status !== 'active' || getActiveStep(reviewing).type !== 'review') { + throw new Error('动作首帧已确认,但任务尚未进入审核阶段') + } + return service.getActionReview(reviewing.id) +} + +function getActiveStep(run: WorkflowRun): WorkflowStep { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + const active = revision.steps.find((step) => step.status === 'active') + if (!active) throw new Error(`WorkflowRun ${run.id} 没有 active 步骤`) + return active +} + +async function toActionSnapshot( + run: WorkflowRun, + service: WorkflowRunService, +): Promise { + if (run.status !== 'active') return { phase: 'terminal', run } + const active = getActiveStep(run) + if (active.type === 'review') { + const review = await service.getActionReview(run.id) + return { phase: 'action-review', ...review } + } + throw new Error(`动画恢复后未进入审核:${active.type}`) +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce879..ded5344 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,12 @@ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowStep, -} from '@/entities' - -/** 更新当前 Revision 中某个步骤的业务数据。 */ -export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] - data: unknown -} - -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] -} - -/** 把某次服务端调用的结果写回目标步骤。 */ -export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - result: unknown -} - /** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 + * WorkflowController Feature 公开入口。 * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 + * pages 只从这里获取创作流程命令,不直接依赖 Controller 内部文件。 */ -export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ - create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程快照。 */ - getWorkflow(): WorkflowRun - - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise - - /** 连续推进到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise - - /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ - interrupt(): Promise -} +export { createWorkflowController } from './controller' +export type { + CreateWorkflowControllerOptions, + WorkflowController, + WorkflowControllerSnapshot, +} from './controller' diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx new file mode 100644 index 0000000..e572ec4 --- /dev/null +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -0,0 +1,362 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { + ActionFirstFrameCandidateBatch, + ActionReviewResult, + CharacterCandidateBatch, + PublishActionResult, + WorkflowRun, +} from '@/entities' +import type { WorkflowController } from '@/features/workflow-controller' +import { WorkflowEditorPage } from '.' + +afterEach(cleanup) + +describe('WorkflowEditorPage', () => { + it('明确提示真实 Controller 尚未装配', () => { + renderEditor('/workflow-editor') + + expect(screen.getByRole('heading', { name: '工作流编辑器' })).toBeTruthy() + expect(screen.getByText('工作流服务尚未装配')).toBeTruthy() + }) + + it('从空画布创建角色任务并展示四张候选', async () => { + const batch = characterCandidates() + const controller = createController({ startCharacter: vi.fn().mockResolvedValue(batch) }) + const onRunCreated = vi.fn() + renderEditor('/workflow-editor', controller, { onRunCreated }) + + fireEvent.change(screen.getByLabelText('项目 ID'), { target: { value: 'project-7' } }) + fireEvent.change(screen.getByLabelText('角色描述'), { + target: { value: ' 红发机械师 ' }, + }) + fireEvent.click(screen.getByRole('button', { name: '生成角色候选' })) + + await screen.findByRole('button', { name: '选择角色候选 1' }) + expect(controller.startCharacter).toHaveBeenCalledWith({ + projectId: 'project-7', + prompt: '红发机械师', + driver: 'manual', + }) + expect(onRunCreated).toHaveBeenCalledWith('run-character') + expect(screen.getAllByRole('button', { name: /选择角色候选/ })).toHaveLength(4) + }) + + it('刷新后通过 Controller 恢复候选并确认角色', async () => { + const batch = characterCandidates() + const completed = characterRun('completed') + const controller = createController({ + resume: vi.fn().mockResolvedValue({ phase: 'character-candidates', ...batch }), + confirmCharacter: vi.fn().mockResolvedValue(completed), + }) + renderEditor('/workflow-editor/run-character', controller) + + const candidate = await screen.findByRole('button', { name: '选择角色候选 2' }) + fireEvent.click(candidate) + fireEvent.click(screen.getByRole('button', { name: '确认角色形象' })) + + await screen.findByRole('button', { name: '添加后续节点' }) + expect(controller.resume).toHaveBeenCalledWith('run-character') + expect(controller.confirmCharacter).toHaveBeenCalledWith({ + runId: 'run-character', + selectedImageUrl: 'https://img.test/character-2.png', + }) + }) + + it('母版加号只展示合法动作,并创建独立动作任务', async () => { + const completed = characterRun('completed') + const actionBatch = actionCandidates() + const controller = createController({ + resume: vi.fn().mockResolvedValue({ phase: 'terminal', run: completed }), + startAction: vi.fn().mockResolvedValue(actionBatch), + }) + const onRunCreated = vi.fn() + renderEditor('/workflow-editor/run-character', controller, { onRunCreated }) + + fireEvent.click(await screen.findByRole('button', { name: '添加后续节点' })) + expect(screen.getByRole('menu')).toBeTruthy() + expect(screen.queryByText('连接任意节点')).toBeNull() + fireEvent.click(screen.getByRole('menuitem', { name: '行走动作' })) + fireEvent.change(screen.getByLabelText('动作描述'), { + target: { value: ' 步伐轻快,身体稳定 ' }, + }) + fireEvent.click(screen.getByRole('button', { name: '生成动作首帧' })) + + await screen.findByRole('button', { name: '选择动作首帧 1' }) + expect(controller.startAction).toHaveBeenCalledWith({ + projectId: 'project-7', + characterId: 'character-7', + outfitId: 'outfit-7', + actionName: '行走', + actionType: 'walk', + prompt: '步伐轻快,身体稳定', + fps: 8, + driver: 'manual', + }) + expect(onRunCreated).toHaveBeenCalledWith('run-action') + }) + + it('确认首帧会直接生成完整动画并进入审核', async () => { + const batch = actionCandidates() + const review = actionReview() + const controller = createController({ + resume: vi.fn().mockResolvedValue({ + phase: 'action-first-frame-candidates', + ...batch, + }), + confirmActionFirstFrame: vi.fn().mockResolvedValue(review), + }) + renderEditor('/workflow-editor/run-action', controller) + + fireEvent.click(await screen.findByRole('button', { name: '选择动作首帧 3' })) + fireEvent.click(screen.getByRole('button', { name: '确认首帧并生成完整动画' })) + + await screen.findByRole('heading', { name: '动画审核' }) + expect(controller.confirmActionFirstFrame).toHaveBeenCalledWith({ + runId: 'run-action', + selectedImageUrl: 'https://img.test/first-frame-3.png', + }) + expect(screen.getAllByRole('img', { name: /动画帧/ })).toHaveLength(3) + }) + + it('审核通过后使用 Controller 返回的稳定 ID 打开 Playtest', async () => { + const review = actionReview() + const published = publishResult() + const controller = createController({ + resume: vi.fn().mockResolvedValue({ phase: 'action-review', ...review }), + approveAction: vi.fn().mockResolvedValue(published), + }) + const onOpenPlaytest = vi.fn() + renderEditor('/workflow-editor/run-action', controller, { onOpenPlaytest }) + + fireEvent.click(await screen.findByRole('button', { name: '审核通过并打开 Playtest' })) + + await waitFor(() => expect(onOpenPlaytest).toHaveBeenCalledWith(published)) + expect(controller.approveAction).toHaveBeenCalledWith('run-action') + }) + + it('生成请求进行中禁用重复提交', async () => { + let resolveBatch!: (batch: CharacterCandidateBatch) => void + const pending = new Promise((resolve) => { + resolveBatch = resolve + }) + const controller = createController({ startCharacter: vi.fn().mockReturnValue(pending) }) + renderEditor('/workflow-editor', controller) + + fireEvent.change(screen.getByLabelText('项目 ID'), { target: { value: 'project-7' } }) + fireEvent.change(screen.getByLabelText('角色描述'), { target: { value: '机械师' } }) + const submit = screen.getByRole('button', { name: '生成角色候选' }) + fireEvent.click(submit) + fireEvent.click(submit) + + expect(controller.startCharacter).toHaveBeenCalledTimes(1) + expect((submit as HTMLButtonElement).disabled).toBe(true) + resolveBatch(characterCandidates()) + await screen.findByRole('button', { name: '选择角色候选 1' }) + }) + + it('恢复失败后可以在原地址重试', async () => { + const batch = characterCandidates() + const controller = createController({ + resume: vi + .fn() + .mockRejectedValueOnce(new Error('任务服务暂时不可用')) + .mockResolvedValueOnce({ phase: 'character-candidates', ...batch }), + }) + renderEditor('/workflow-editor/run-character', controller) + + expect((await screen.findByRole('alert')).textContent).toContain('任务服务暂时不可用') + fireEvent.click(screen.getByRole('button', { name: '重新恢复' })) + + await screen.findByRole('button', { name: '选择角色候选 1' }) + expect(controller.resume).toHaveBeenCalledTimes(2) + }) +}) + +interface RenderOptions { + onRunCreated?(runId: string): void + onOpenPlaytest?(result: PublishActionResult): void +} + +function renderEditor( + initialEntry: string, + controller?: WorkflowController, + options: RenderOptions = {}, +) { + return render( + + + } + /> + } + /> + + , + ) +} + +function createController( + overrides: Partial = {}, +): WorkflowController & Record> { + const controller = { + startCharacter: vi.fn(), + confirmCharacter: vi.fn(), + startAction: vi.fn(), + confirmActionFirstFrame: vi.fn(), + approveAction: vi.fn(), + interrupt: vi.fn(), + getWorkflow: vi.fn().mockReturnValue(null), + listWorkflows: vi.fn().mockReturnValue([]), + subscribe: vi.fn().mockReturnValue(() => undefined), + subscribeAll: vi.fn().mockReturnValue(() => undefined), + resume: vi.fn().mockResolvedValue(null), + ...overrides, + } + return controller as WorkflowController & + Record> +} + +function characterCandidates(): CharacterCandidateBatch { + return { + run: characterRun('active'), + generationId: 'generation-character', + candidates: [1, 2, 3, 4].map((index) => `https://img.test/character-${index}.png`), + } +} + +function actionCandidates(): ActionFirstFrameCandidateBatch { + return { + run: actionRun('first-frame-candidate'), + candidateTaskIds: ['task-1', 'task-2', 'task-3', 'task-4'], + candidates: [1, 2, 3, 4].map((index) => `https://img.test/first-frame-${index}.png`), + } +} + +function actionReview(): ActionReviewResult { + return { + run: actionRun('review'), + generationId: 'generation-animation', + frames: [1, 2, 3].map((index) => ({ imageUrl: `https://img.test/frame-${index}.png` })), + } +} + +function publishResult(): PublishActionResult { + return { + run: actionRun('completed'), + character: { + id: 'character-7', + projectId: 'project-7', + outfits: [], + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + characterId: 'character-7', + outfitId: 'outfit-7', + actionId: 'action-7', + } +} + +function characterRun(status: 'active' | 'completed'): WorkflowRun { + const steps = ['character-setup', 'character-template', 'template-candidate'] as const + return { + id: 'run-character', + projectId: 'project-7', + purpose: 'create_character', + driver: 'manual', + status, + currentRevisionId: 'revision-character', + revisions: [ + { + id: 'revision-character', + basedOnRevisionId: null, + restartStepId: null, + status: status === 'completed' ? 'completed' : 'active', + steps: steps.map((type, index) => ({ + id: `step-character-${index}`, + type, + status: status === 'completed' ? 'passed' : index === 2 ? 'active' : 'passed', + taskId: type === 'character-template' ? 'generation-character' : null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })), + generationStatus: 'completed', + exportStatus: 'not_exported', + createdAt: '2026-08-04T00:00:00.000Z', + }, + ], + prompt: '红发机械师', + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + characterId: status === 'completed' ? 'character-7' : null, + outfitId: status === 'completed' ? 'outfit-7' : null, + selectedAt: status === 'completed' ? '2026-08-04T00:01:00.000Z' : null, + } as WorkflowRun +} + +function actionRun( + phase: 'first-frame-candidate' | 'review' | 'completed', +): Extract { + const types = [ + 'action-setup', + 'first-frame', + 'first-frame-candidate', + 'complete-animation', + 'review', + 'export', + ] as const + const activeIndex = phase === 'first-frame-candidate' ? 2 : phase === 'review' ? 4 : -1 + return { + id: 'run-action', + projectId: 'project-7', + purpose: 'add_action', + driver: 'manual', + status: phase === 'completed' ? 'completed' : 'active', + currentRevisionId: 'revision-action', + revisions: [ + { + id: 'revision-action', + basedOnRevisionId: null, + restartStepId: null, + status: phase === 'completed' ? 'completed' : 'active', + steps: types.map((type, index) => ({ + id: `step-action-${index}`, + type, + status: + phase === 'completed' + ? 'passed' + : index < activeIndex + ? 'passed' + : index === activeIndex + ? 'active' + : 'locked', + taskId: type === 'complete-animation' ? 'generation-animation' : null, + candidateTaskIds: type === 'first-frame' ? ['task-1', 'task-2', 'task-3', 'task-4'] : [], + submissionId: null, + error: null, + referenceStepIds: [], + })), + generationStatus: phase === 'completed' ? 'completed' : 'in_progress', + exportStatus: phase === 'completed' ? 'exported' : 'not_exported', + createdAt: '2026-08-04T00:00:00.000Z', + }, + ], + prompt: '步伐轻快,身体稳定', + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + characterId: 'character-7', + outfitId: 'outfit-7', + actionId: 'action-7', + actionName: '行走', + actionType: 'walk', + fps: 8, + } +} diff --git a/frontend/src/pages/workflow-editor/index.ts b/frontend/src/pages/workflow-editor/index.ts new file mode 100644 index 0000000..188fff4 --- /dev/null +++ b/frontend/src/pages/workflow-editor/index.ts @@ -0,0 +1,8 @@ +/** + * Workflow Editor Page 的公开入口。 + * + * App 路由和测试只从目录入口引用页面,避免依赖内部文件布局;页面实现、 + * 卡片组件与样式可以在本目录继续拆分,而不会让上层路由同步改 import。 + */ +export { WorkflowEditorPage } from './page' +export type { WorkflowEditorPageProps } from './page' diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx deleted file mode 100644 index 9c5afec..0000000 --- a/frontend/src/pages/workflow-editor/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { PageContainer } from '@/shared/ui' - -/** 工作流画布。 */ -export function WorkflowEditorPage() { - return ( - -
-

工作流画布

-

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

-
-
- ) -} diff --git a/frontend/src/pages/workflow-editor/page.tsx b/frontend/src/pages/workflow-editor/page.tsx new file mode 100644 index 0000000..d0573d2 --- /dev/null +++ b/frontend/src/pages/workflow-editor/page.tsx @@ -0,0 +1,697 @@ +import { + type DragEvent, + type FormEvent, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useNavigate, useParams } from 'react-router' + +import type { ActionType, PublishActionResult, WorkflowRun } from '@/entities' +import type { WorkflowController, WorkflowControllerSnapshot } from '@/features/workflow-controller' +import './workflow-editor.css' + +export interface WorkflowEditorPageProps { + /** + * 页面唯一的业务入口。App 装配尚未完成时可以省略,页面会明确显示不可用, + * 而不是创建假 Controller 或伪造生成结果。 + */ + controller?: WorkflowController + /** 创建角色 Run 或动作 Run 后通知路由层;默认更新到该 Run 的恢复地址。 */ + onRunCreated?(runId: WorkflowRun['id']): void + /** 审核通过后交付稳定资产 ID;默认打开对应 Playtest 动作。 */ + onOpenPlaytest?(result: PublishActionResult): void +} + +interface ActionDraft { + type: ActionType + name: string + prompt: string + fps: number +} + +const ACTION_OPTIONS: readonly { type: ActionType; label: string; name: string }[] = [ + { type: 'idle', label: '待机动作', name: '待机' }, + { type: 'walk', label: '行走动作', name: '行走' }, + { type: 'jump', label: '跳跃动作', name: '跳跃' }, + { type: 'attack', label: '攻击动作', name: '攻击' }, + { type: 'custom', label: '自定义动作', name: '' }, +] + +/** + * 手动工作流编辑器。 + * + * 组件只保留输入框、当前选中候选和临时图片 URL。WorkflowRun、步骤状态、 + * Generation ID 与正式资产 ID 均由 Controller/Entity 维护,刷新时统一 resume。 + */ +export function WorkflowEditorPage({ + controller, + onRunCreated, + onOpenPlaytest, +}: WorkflowEditorPageProps) { + const { runId } = useParams() + const navigate = useNavigate() + const [snapshot, setSnapshot] = useState(null) + const [loading, setLoading] = useState(Boolean(controller && runId)) + const [resumeAttempt, setResumeAttempt] = useState(0) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [projectId, setProjectId] = useState('') + const [characterPrompt, setCharacterPrompt] = useState('') + const [selectedCandidate, setSelectedCandidate] = useState(null) + const [menuOpen, setMenuOpen] = useState(false) + const [actionDraft, setActionDraft] = useState(null) + + const openRun = useMemo( + () => onRunCreated ?? ((id: string) => navigate(`/workflow-editor/${encodeURIComponent(id)}`)), + [navigate, onRunCreated], + ) + const openPlaytest = useMemo( + () => + onOpenPlaytest ?? + ((result: PublishActionResult) => { + const path = `/playtest/${encodeURIComponent(result.characterId)}/${encodeURIComponent(result.outfitId)}` + navigate(`${path}?actionId=${encodeURIComponent(result.actionId)}`) + }), + [navigate, onOpenPlaytest], + ) + + useEffect(() => { + if (!controller || !runId) { + setLoading(false) + if (!runId) setSnapshot(null) + return + } + + let active = true + setLoading(true) + setError(null) + setSelectedCandidate(null) + setActionDraft(null) + + // 页面不读取 Store,也不根据步骤名自行恢复。Controller 返回可直接渲染的 phase。 + void controller.resume(runId).then( + (restored) => { + if (!active) return + setSnapshot(restored) + setLoading(false) + if (!restored) setError('没有找到这条工作流任务') + }, + (cause: unknown) => { + if (!active) return + setError(errorMessage(cause, '工作流恢复失败')) + setLoading(false) + }, + ) + + const unsubscribe = controller.subscribe(runId, (run) => { + if (active) setSnapshot((current) => replaceSnapshotRun(current, run)) + }) + return () => { + active = false + unsubscribe() + } + }, [controller, resumeAttempt, runId]) + + if (!controller) { + return ( + + + 当前页面不会使用假数据。请由应用装配 WorkflowController 后再开始任务。 + + + ) + } + const activeController = controller + + async function perform(operation: () => Promise) { + if (busy) return + setBusy(true) + setError(null) + try { + await operation() + } catch (cause) { + setError(errorMessage(cause, '操作失败,请重试')) + } finally { + setBusy(false) + } + } + + function startCharacter(event: FormEvent) { + event.preventDefault() + const normalizedProjectId = projectId.trim() + const prompt = characterPrompt.trim() + if (!normalizedProjectId || !prompt) { + setError('请填写项目 ID 和角色描述') + return + } + void perform(async () => { + const batch = await activeController.startCharacter({ + projectId: normalizedProjectId, + prompt, + driver: 'manual', + }) + setSnapshot({ phase: 'character-candidates', ...batch }) + setSelectedCandidate(null) + openRun(batch.run.id) + }) + } + + function confirmCharacter() { + if (!selectedCandidate || snapshot?.phase !== 'character-candidates') return + void perform(async () => { + const run = await activeController.confirmCharacter({ + runId: snapshot.run.id, + selectedImageUrl: selectedCandidate, + }) + setSnapshot({ phase: 'terminal', run }) + setSelectedCandidate(null) + }) + } + + function chooseAction(type: ActionType) { + const option = ACTION_OPTIONS.find((item) => item.type === type) + if (!option) return + setActionDraft({ type, name: option.name, prompt: '', fps: 8 }) + setMenuOpen(false) + } + + function startAction(event: FormEvent) { + event.preventDefault() + if (!actionDraft || snapshot?.phase !== 'terminal') return + const source = snapshot.run + if ( + source.purpose !== 'create_character' || + source.status !== 'completed' || + !source.characterId || + !source.outfitId + ) { + setError('角色尚未保存,不能创建动作') + return + } + const actionName = actionDraft.name.trim() + if (!actionName) { + setError('请填写动作名称') + return + } + void perform(async () => { + const batch = await activeController.startAction({ + projectId: source.projectId, + characterId: source.characterId, + outfitId: source.outfitId, + actionName, + actionType: actionDraft.type, + prompt: actionDraft.prompt.trim() || null, + fps: actionDraft.fps, + driver: 'manual', + }) + setSnapshot({ phase: 'action-first-frame-candidates', ...batch }) + setSelectedCandidate(null) + setActionDraft(null) + openRun(batch.run.id) + }) + } + + function confirmFirstFrame() { + if (!selectedCandidate || snapshot?.phase !== 'action-first-frame-candidates') return + void perform(async () => { + const review = await activeController.confirmActionFirstFrame({ + runId: snapshot.run.id, + selectedImageUrl: selectedCandidate, + }) + setSnapshot({ phase: 'action-review', ...review }) + setSelectedCandidate(null) + }) + } + + function approveAction() { + if (snapshot?.phase !== 'action-review') return + void perform(async () => { + const result = await activeController.approveAction(snapshot.run.id) + setSnapshot({ phase: 'terminal', run: result.run }) + openPlaytest(result) + }) + } + + return ( + + {error ? ( +
+ {error} + {runId && !loading ? ( + + ) : null} +
+ ) : null} + {loading ? 读取当前候选与审核阶段。 : null} + {!loading && !runId && !snapshot ? ( + + ) : null} + {!loading && snapshot ? ( + + {renderSnapshot(snapshot, { + busy, + selectedCandidate, + menuOpen, + actionDraft, + onSelectCandidate: setSelectedCandidate, + onConfirmCharacter: confirmCharacter, + onToggleMenu: () => setMenuOpen((current) => !current), + onChooseAction: chooseAction, + onActionDraftChange: setActionDraft, + onStartAction: startAction, + onConfirmFirstFrame: confirmFirstFrame, + onApproveAction: approveAction, + })} + + ) : null} +
+ ) +} + +interface RenderSnapshotOptions { + busy: boolean + selectedCandidate: string | null + menuOpen: boolean + actionDraft: ActionDraft | null + onSelectCandidate(url: string): void + onConfirmCharacter(): void + onToggleMenu(): void + onChooseAction(type: ActionType): void + onActionDraftChange(draft: ActionDraft): void + onStartAction(event: FormEvent): void + onConfirmFirstFrame(): void + onApproveAction(): void +} + +/** phase 到卡片的映射只决定显示内容,不推进 WorkflowRun。 */ +function renderSnapshot(snapshot: WorkflowControllerSnapshot, options: RenderSnapshotOptions) { + if (snapshot.phase === 'character-setup') { + return 请从新任务入口补充角色描述。 + } + if (snapshot.phase === 'character-candidates') { + return ( + <> + + + + ) + } + if (snapshot.phase === 'action-setup') { + return 请从角色母版的加号重新选择动作。 + } + if (snapshot.phase === 'action-first-frame-candidates') { + return ( + <> + + + + + ) + } + if (snapshot.phase === 'action-review') { + return ( + <> + + + + + ) + } + + const run = snapshot.run + if (isCompletedCharacter(run)) { + return ( + <> + + +

角色 {run.characterId}

+

造型 {run.outfitId}

+ + {options.menuOpen ? ( +
+ {ACTION_OPTIONS.map((action) => ( + + ))} +
+ ) : null} +
+ {options.actionDraft ? ( + + ) : null} + + ) + } + if (run.purpose === 'add_action' && run.status === 'completed') { + return + } + return ( + + 任务快照仍由 WorkflowRun 保存,可从历史记录或当前地址继续。 + + ) +} + +function CharacterSetupCard({ + projectId, + prompt, + busy, + onProjectIdChange, + onPromptChange, + onSubmit, +}: { + projectId: string + prompt: string + busy: boolean + onProjectIdChange(value: string): void + onPromptChange(value: string): void + onSubmit(event: FormEvent): void +}) { + return ( + + +
+ +