Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/entities/generation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase {
type: 'character_template'
/** 已由手动输入或 Quick Start 整理好的角色提示词。 */
prompt: string
/** 必须与 Project 的精灵尺寸一致,后端会在提交时校验。 */
spriteWidth: number
spriteHeight: number
}

/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,14 @@ export type { MediaReference } from './media'
/* 工作流 —— 前端管理节点,后端只持久化完整 nodes 文档 */
export { workflowRunApis } from './workflow-run'
export type {
ActionWorkflowNode,
CharacterWorkflowNode,
ActionFirstFrameWorkflowNode,
ActionFullFrameWorkflowNode,
ActionGenerationMethod,
ActionGenerationMethodWorkflowNode,
CharacterSetupWorkflowNode,
CharacterTemplateWorkflowNode,
CreateWorkflowRunInput,
ReviewWorkflowNode,
WorkflowActionInput,
WorkflowCharacterInput,
WorkflowGenerationRef,
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/entities/workflow-run/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@

- 前后端统一使用 `WorkflowNode`。原先前端的 Step 与后端的 Node 是同一概念,已经合并。
- `WorkflowRun.nodes` 直接保存真实节点,不再使用 `root.steps` 或人为包装的根节点。
- 一个节点与 Workflow Editor 中一张卡片一一对应;生成与选择是节点内部 phase,不拆成额外节点。
- 六类节点与 Workflow Editor 的六类卡片一一对应:角色设定、角色母版、动作首帧、资产生成方式、完整动画和审核。
- “提交中、生成中、选择中”仍是节点内部 phase,不再拆成 Step 或额外任务节点。
- 节点通过 `dependsOnNodeIds` 保存直接前置依赖,因此边会与节点一起落库,不再依赖数组顺序猜测连线。
- 多个 Action 节点可以依赖同一个角色节点;前置节点通过后即可并行,不互相阻塞。
- 每个 Action 使用 `action-first-frame -> action-generation-method -> action-full-frame -> review` 四节点链;多条链共同依赖
`character-template`,角色母版通过后即可并行,不互相阻塞。
- 资产生成方式当前可选 `video-cropping` 与 `3d-to-2d`。3D 转 2D 后端接口未提供前只保存选择并明确阻止提交,不伪装成视频路线。
- Quick Start 与 Workflow Editor 是两种独立界面,但推进同一张节点图,核心数据不区分 `ai/manual driver`。
- 后端不提供 Revision 历史。重做时覆盖旧结果,并用 `nodeId + taskId` 防止旧请求串线。
- 已发布 Action 被删除后,对应四节点分支以 `deletedAt` 标记并继续留在图中,生成输入、任务引用和审核历史不会被擦除;角色母版及其他并行动作不受影响。

## 前后端边界

Expand All @@ -25,4 +29,4 @@ HTTP 接口严格对应 `POST /workflow-runs`、`GET/PATCH/DELETE /workflow-runs
- `constants.ts`:核心节点状态、类型和 phase。
- `index.ts`:WorkflowRun、WorkflowNode 与 API 类型。
- `api.ts`:后端 DTO 映射、节点图校验和 HTTP 适配。
- `api.test.ts`:直接节点映射、边校验及并行 Action 数据测试。
- `api.test.ts`:直接节点映射、边校验、删除标记及并行 Action 数据测试。
181 changes: 159 additions & 22 deletions frontend/src/entities/workflow-run/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,63 @@ import type { WorkflowNode } from './index'

const nodes: WorkflowNode[] = [
{
id: 'character-node',
type: 'character',
id: 'setup-node',
type: 'character-setup',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: [],
generations: [{ taskId: '91', role: 'character_candidates' }],
generations: [],
error: null,
input: { prompt: '一个像素骑士', referenceMedia: [] },
},
{
id: 'template-node',
type: 'character-template',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['setup-node'],
generations: [{ taskId: '91', role: 'character_template' }],
error: null,
selectedImageUrl: 'https://cdn.windup.test/character.png',
},
{
id: 'walk-node',
type: 'action',
status: 'active',
phase: 'generating_animation',
dependsOnNodeIds: ['character-node'],
generations: [{ taskId: '92', role: 'animation' }],
id: 'walk-first-frame',
type: 'action-first-frame',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['template-node'],
generations: [{ taskId: '92', role: 'first_frame' }],
error: null,
input: { outfitId: 'outfit-1', name: '行走', type: 'walk', prompt: null, fps: 12 },
selectedFirstFrameUrl: 'https://cdn.windup.test/walk-first.png',
},
{
id: 'jump-node',
type: 'action',
id: 'walk-generation-method',
type: 'action-generation-method',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['walk-first-frame'],
generations: [],
error: null,
method: 'video-cropping',
},
{
id: 'walk-full-frame',
type: 'action-full-frame',
status: 'active',
phase: 'generating_animation',
dependsOnNodeIds: ['character-node'],
generations: [{ taskId: '93', role: 'animation' }],
phase: 'generating',
dependsOnNodeIds: ['walk-generation-method'],
generations: [{ taskId: '93', role: 'complete_animation' }],
error: null,
},
{
id: 'walk-review',
type: 'review',
status: 'locked',
phase: 'reviewing',
dependsOnNodeIds: ['walk-full-frame'],
generations: [],
error: null,
input: { outfitId: 'outfit-1', name: '跳跃', type: 'jump', prompt: null, fps: 12 },
selectedFirstFrameUrl: 'https://cdn.windup.test/jump-first.png',
},
]

Expand Down Expand Up @@ -64,6 +90,85 @@ function jsonResponse(data: unknown) {
}

describe('workflowRunApis', () => {
it('hydrates the six visible workflow nodes with explicit dependency edges', async () => {
const sixNodeDto = {
...workflowRunDto,
nodes: [
{
id: 'setup-1',
type: 'character-setup',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: [],
generations: [],
error: null,
input: { prompt: 'pixel knight', referenceMedia: [] },
},
{
id: 'template-1',
type: 'character-template',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['setup-1'],
generations: [{ taskId: 'task-template', role: 'character_template' }],
error: null,
selectedImageUrl: 'https://img/knight.png',
},
{
id: 'first-frame-1',
type: 'action-first-frame',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['template-1'],
generations: [{ taskId: 'task-frame', role: 'first_frame' }],
error: null,
input: { outfitId: 'outfit-1', name: 'walk', type: 'walk', prompt: null, fps: 12 },
selectedFirstFrameUrl: 'https://img/walk-first.png',
},
{
id: 'generation-method-1',
type: 'action-generation-method',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['first-frame-1'],
generations: [],
error: null,
method: 'video-cropping',
},
{
id: 'full-frame-1',
type: 'action-full-frame',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['generation-method-1'],
generations: [{ taskId: 'task-animation', role: 'complete_animation' }],
error: null,
},
{
id: 'review-1',
type: 'review',
status: 'active',
phase: 'reviewing',
dependsOnNodeIds: ['full-frame-1'],
generations: [],
error: null,
},
],
}
const apis = await loadWorkflowRunApis(async () => jsonResponse(sixNodeDto))

await expect(apis.get('17')).resolves.toMatchObject({
nodes: [
{ type: 'character-setup', dependsOnNodeIds: [] },
{ type: 'character-template', dependsOnNodeIds: ['setup-1'] },
{ type: 'action-first-frame', dependsOnNodeIds: ['template-1'] },
{ type: 'action-generation-method', dependsOnNodeIds: ['first-frame-1'] },
{ type: 'action-full-frame', dependsOnNodeIds: ['generation-method-1'] },
{ type: 'review', dependsOnNodeIds: ['full-frame-1'] },
],
})
})

it('persists frontend nodes directly without a synthetic root node', async () => {
let request: Request | undefined
const apis = await loadWorkflowRunApis(async (input, init) => {
Expand Down Expand Up @@ -133,12 +238,28 @@ describe('workflowRunApis', () => {
})
})

it('rejects an empty deletion marker instead of treating it as archived history', async () => {
const apis = await loadWorkflowRunApis(async () =>
jsonResponse({
...workflowRunDto,
nodes: nodes.map((node) =>
node.id === 'walk-full-frame' ? { ...node, deletedAt: '' } : node,
),
}),
)

await expect(apis.get('17')).rejects.toMatchObject({
name: 'ApiError',
kind: 'invalid-response',
})
})

it('rejects a dependency that points outside the persisted graph', async () => {
const apis = await loadWorkflowRunApis(async () =>
jsonResponse({
...workflowRunDto,
nodes: nodes.map((node) =>
node.id === 'walk-node' ? { ...node, dependsOnNodeIds: ['missing-node'] } : node,
node.id === 'walk-full-frame' ? { ...node, dependsOnNodeIds: ['missing-node'] } : node,
),
}),
)
Expand All @@ -153,7 +274,7 @@ describe('workflowRunApis', () => {
jsonResponse({
...workflowRunDto,
nodes: nodes.map((node) =>
node.id === 'character-node' ? { ...node, dependsOnNodeIds: ['walk-node'] } : node,
node.id === 'setup-node' ? { ...node, dependsOnNodeIds: ['walk-review'] } : node,
),
}),
)
Expand All @@ -166,21 +287,21 @@ describe('workflowRunApis', () => {
it('accepts an action-only graph for adding an action to an existing character', async () => {
const actionOnlyDto = {
...workflowRunDto,
nodes: [{ ...nodes[1], dependsOnNodeIds: [] }],
nodes: [{ ...nodes[2], dependsOnNodeIds: [] }],
}
const apis = await loadWorkflowRunApis(async () => jsonResponse(actionOnlyDto))
await expect(apis.get('17')).resolves.toMatchObject({ nodes: actionOnlyDto.nodes })
})

it('rejects completed nodes that lost their selected asset', async () => {
const completedActionWithoutSelection = {
...nodes[1],
...nodes[2],
status: 'passed' as const,
phase: 'completed' as const,
selectedFirstFrameUrl: null,
}
const apis = await loadWorkflowRunApis(async () =>
jsonResponse({ ...workflowRunDto, nodes: [nodes[0], completedActionWithoutSelection] }),
jsonResponse({ ...workflowRunDto, nodes: [completedActionWithoutSelection] }),
)
await expect(apis.get('17')).rejects.toMatchObject({
name: 'ApiError',
Expand All @@ -190,7 +311,7 @@ describe('workflowRunApis', () => {

it('rejects a completed character node that lost its selected image', async () => {
const completedCharacterWithoutSelection = {
...nodes[0],
...nodes[1],
selectedImageUrl: null,
}
const apis = await loadWorkflowRunApis(async () =>
Expand All @@ -201,4 +322,20 @@ describe('workflowRunApis', () => {
kind: 'invalid-response',
})
})

it('rejects a passed node whose phase is not completed', async () => {
const passedCharacterStillConfiguring = {
...nodes[0],
status: 'passed' as const,
phase: 'configuring' as const,
}
const apis = await loadWorkflowRunApis(async () =>
jsonResponse({ ...workflowRunDto, nodes: [passedCharacterStillConfiguring] }),
)

await expect(apis.get('17')).rejects.toMatchObject({
name: 'ApiError',
kind: 'invalid-response',
})
})
})
Loading