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
27 changes: 26 additions & 1 deletion backend/packages/app/src/windup_app/server/orchestrator/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ class ActionType(StrEnum):
CUSTOM = "custom"


# 动作类型 → 帧数(产品口径)。**这是唯一一份约定**:前端提交时不发 num_frames,
# 从任务的 input_payload 读回来用 —— 两边各写一个数,分叉时任务照跑、没有一处会红。
# 待机是原地小幅呼吸,32 帧里绝大多数帧之间没有差别,多出来的帧进不了有效循环,
# 却照样占抽帧、抠图、对齐、上传的工作量与存储。
ACTION_FRAME_COUNTS: dict[ActionType, int] = {
ActionType.WALK: 32,
ActionType.IDLE: 12,
ActionType.JUMP: 32,
ActionType.ATTACK: 32,
ActionType.CUSTOM: 32,
}


def frames_for(action_type: ActionType) -> int:
"""该动作类型约定的帧数。"""
return ACTION_FRAME_COUNTS[action_type]


class TaskStatus(StrEnum):
"""生成任务状态。"""

Expand Down Expand Up @@ -73,7 +91,10 @@ class CharacterActionInput:
custom_prompt: str | None = None
reference_video_url: str | None = None
reference_image_urls: list[str] = field(default_factory=list)
num_frames: int = 32
# ``None`` = 调用方没指定,在 __post_init__ 里按动作类型解析成约定值。解析放在这层
# 而不是各个构造点:落库的 input_payload 是产线与前端读帧数的唯一来源,少解析一处
# 就多一个自带默认值的构造点(MQ 重建入参就是其中一个)。
num_frames: int | None = None
# ── action_type=custom 才用到的两个(#239)──────────────────────────────
# 这个动作是否循环播放。``None`` 原样往下传,由编排层兜成一次性:本层替调用方填默认值
# 的话,"没给"和"明确给了 False"从这里起就再也分不开了。
Expand All @@ -97,6 +118,10 @@ class CharacterActionInput:
stance: CharacterStance | None = None
direction: ActionDirection = ActionDirection.EAST

def __post_init__(self) -> None:
if self.num_frames is None:
self.num_frames = frames_for(self.action_type)


# -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------

Expand Down
4 changes: 3 additions & 1 deletion backend/packages/app/src/windup_app/web/api/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ class CharacterActionGenerateRequest(BaseModel):
reference_video_url: str | None = None
reference_image_urls: list[str] = Field(default_factory=list)
# 同上:帧数决定抽帧与逐帧抠图的工作量,上界 64 已远超引擎能出的有效周期长度。
num_frames: int = Field(default=32, ge=1, le=64)
# 不给则按动作类型取约定值(ACTION_FRAME_COUNTS)——写死一个默认值就等于替所有动作
# 都答了同一个数,而待机与走路要的帧数本来就不同。
num_frames: int | None = Field(default=None, ge=1, le=64)
# ── action_type=custom 才用到(#239)───────────────────────────────────
# 这个动作是否循环播放。不给则编排层兜成一次性,也不按描述文字猜 —— 两个方向的代价
# 不对称:一次性动作被当成循环会让末帧接回首帧抽搐、产物不可用,反之只是不无缝闭环、
Expand Down
5 changes: 4 additions & 1 deletion backend/packages/app/src/windup_app/worker/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,16 @@ def _image_input(payload: dict) -> CharacterImageInput:
def _action_input(payload: dict) -> CharacterActionInput:
raw_type = payload.get("action_type")
action_type = raw_type if isinstance(raw_type, ActionType) else ActionType(raw_type)
# 帧数缺失时原样传 None,交给入参按动作类型解析:在这里兜一个数就是第二份约定,
# 它与真正的约定分叉时任务照跑、帧数照出,没有一处会红。
raw_frames = payload.get("num_frames")
return CharacterActionInput(
character_id=int(payload["character_id"]),
action_type=action_type,
custom_prompt=payload.get("custom_prompt"),
reference_video_url=payload.get("reference_video_url"),
reference_image_urls=list(payload.get("reference_image_urls") or []),
num_frames=int(payload.get("num_frames") or 16),
num_frames=int(raw_frames) if raw_frames is not None else None,
loop=payload.get("loop"),
video_model=payload.get("video_model"),
outfit_id=payload.get("outfit_id"),
Expand Down
62 changes: 62 additions & 0 deletions backend/tests/test_generation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,3 +475,65 @@ def test_illegal_stance_is_rejected_at_the_entrance(auth_client):
json=_action_payload(project["id"], character["id"], stance="octopod"),
).json()
assert body["code"] == 400, body


# ── 帧数按动作类型取,且只有一份约定 ────────────────────────────────────────
#
# 32 曾同时是后端默认值和前端"这是完整动画任务"的判据,于是改一个动作的帧数会让
# 前端认不出这类任务。现在约定只在 ACTION_FRAME_COUNTS 一处,前端提交时不发帧数。


def test_idle_task_frames_come_from_the_convention(auth_client):
"""待机任务落库的帧数取该动作类型的约定值。

断言的是任务 input_payload —— 前端与 MQ 重建都从它读帧数,只断言函数返回值的话,
请求层没接上也照样绿。
"""
from windup_app.server.orchestrator.model import ActionType, frames_for

project = _create_project(auth_client)
character = _create_character(auth_client, project["id"])

body = auth_client.post(
"/generation/action",
json=_action_payload(project["id"], character["id"], action_type="idle"),
).json()

assert body["data"] is not None, body
stored = body["data"]["input_payload"]["num_frames"]
assert stored == frames_for(ActionType.IDLE)
assert stored != 32, "待机还在按 32 帧生成"


def test_walk_task_keeps_thirty_two_frames(auth_client):
"""本改动只动待机;走路的帧数不变。"""
project = _create_project(auth_client)
character = _create_character(auth_client, project["id"])

body = auth_client.post(
"/generation/action",
json=_action_payload(project["id"], character["id"], action_type="walk"),
).json()

assert body["data"]["input_payload"]["num_frames"] == 32


def test_explicit_num_frames_wins_over_the_convention(auth_client):
project = _create_project(auth_client)
character = _create_character(auth_client, project["id"])

body = auth_client.post(
"/generation/action",
json=_action_payload(
project["id"], character["id"], action_type="idle", num_frames=20,
),
).json()

assert body["data"]["input_payload"]["num_frames"] == 20


def test_frame_convention_covers_every_action_type():
"""新增动作类型必须同时给出帧数 —— 漏了的话取帧数会在请求里抛 KeyError。"""
from windup_app.server.orchestrator.model import ACTION_FRAME_COUNTS, ActionType

assert set(ACTION_FRAME_COUNTS) == set(ActionType)
23 changes: 23 additions & 0 deletions backend/tests/test_mq_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,3 +915,26 @@ def test_consumer_acquires_generation_semaphore(engine, worker_session, monkeypa

redis_mock.xack.assert_called_once()



def test_action_input_takes_frames_from_the_convention():
"""MQ 重建入参时缺帧数就按动作类型取,不在这层兜一个自己的数。

生产走的就是这条重建路径:这里兜的数与约定分叉时,任务照跑、帧照出,没有一处会红。
"""
from windup_app.server.orchestrator.model import frames_for
from windup_app.worker.handlers import _action_input

rebuilt = _action_input({"character_id": 1, "action_type": "idle"})

assert rebuilt.num_frames == frames_for(ActionType.IDLE)
assert rebuilt.num_frames != 32


def test_action_input_keeps_the_stored_frame_count():
"""落库的帧数是产线唯一来源,重建时原样取,不拿约定覆盖它。"""
from windup_app.worker.handlers import _action_input

rebuilt = _action_input({"character_id": 1, "action_type": "idle", "num_frames": 20})

assert rebuilt.num_frames == 20
43 changes: 41 additions & 2 deletions frontend/src/entities/generation/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ describe('createGenerationApis', () => {
).rejects.toThrow('动作首帧生成必须提供已确认的角色母版')
})

it('以首帧请求完整动画并按后端 index 排序,当前合同固定为三十二帧', async () => {
it('以首帧请求完整动画并按后端 index 排序', async () => {
const request = vi.fn(async (_url: string, _init?: RequestInit) =>
success(
taskData({
Expand Down Expand Up @@ -337,7 +337,6 @@ describe('createGenerationApis', () => {
custom_prompt: 'move forward',
reference_video_url: null,
reference_image_urls: ['https://cdn.test/frame-1.png', 'https://cdn.test/extra.png'],
num_frames: 32,
outfit_id: 'default',
direction: 'east',
})
Expand All @@ -351,6 +350,46 @@ describe('createGenerationApis', () => {
})
})

it('提交动作不发帧数,待机任务的十二帧照样识别得出阶段并映射结果', async () => {
const request = vi.fn(async (_url: string, _init?: RequestInit) =>
success(
taskData({
task_type: 'character_action',
input_payload: { num_frames: 12, action_type: 'idle' },
result: {
type: 'character_action',
action_type: 'idle',
frames: actionFrames(12),
},
}),
),
)
const apis = createGenerationApis({
transport: { request, stream: vi.fn(() => vi.fn()) },
})

const created = await apis.create({
type: 'complete_animation',
projectId: '42',
characterId: '5',
outfitId: 'default',
method: 'video-cropping',
actionType: 'idle',
firstFrameUrl: 'https://cdn.test/frame-1.png',
prompt: null,
referenceMedia: [],
})
// 不带 expectation 查一次,走的是 inferExpectation:它必须只认 task_type 与
// action_type,认帧数的话待机任务在这里就抛"无法映射到前端阶段"。
const recovered = await apis.get('42', '91')

expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).not.toHaveProperty('num_frames')
expect(created.result).toMatchObject({ type: 'complete_animation' })
expect((created.result as { frames: readonly unknown[] }).frames).toHaveLength(12)
expect(recovered.type).toBe('complete_animation')
expect((recovered.result as { frames: readonly unknown[] }).frames).toHaveLength(12)
})

it('选视频裁剪时不发 outfit_id——后端拿它在场与否当三渲二的唯一判据', async () => {
const request = vi.fn(async (_url: string, _init?: RequestInit) =>
success(
Expand Down
73 changes: 49 additions & 24 deletions frontend/src/entities/generation/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,26 @@ function mapImageResult(
: { type: expectation.type, direction: expectation.direction, images }
}

/**
* 任务声明的帧数。哪种动作出多少帧由后端定,前端读回来当结果帧数的判据——
* 在前端也写一个数就是第二份约定,与后端分叉时两边都不会报错。
*/
function declaredFrameCount(
inputPayload: Record<string, unknown> | null,
expectation: GenerationExpectation,
): number | undefined {
if (expectation.type !== 'complete_animation' || inputPayload === null) return undefined
const value = inputPayload.num_frames
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
throw new GenerationApiError('动作任务 input_payload.num_frames 无效', 200)
}
return value as number
}

function mapActionResult(
result: Record<string, unknown>,
expectation: Extract<GenerationExpectation, { type: 'complete_animation' }>,
frameCount: number | undefined,
): GenerationResult {
if (result.type !== 'character_action') {
throw new GenerationApiError('完整动画结果 type 无效', 200)
Expand Down Expand Up @@ -293,7 +310,8 @@ function mapActionResult(
})

const orderedFrames = frames.sort((left, right) => left.index - right.index)
const expectedFrameCount = 32
// 事件没带 input_payload 时无从比对帧数,退回只查连续性——不能拿一个前端猜的数当判据。
const expectedFrameCount = frameCount ?? orderedFrames.length
if (orderedFrames.length !== expectedFrameCount) {
throw new GenerationApiError(`完整动画结果必须包含 ${expectedFrameCount} 帧`, 200)
}
Expand All @@ -316,6 +334,7 @@ function mapResult(
status: TaskStatus,
expectation: GenerationExpectation,
expectedCandidateCount?: ImageCandidateCount,
frameCount?: number,
): GenerationResult | null {
if (status !== 'completed') {
if (result !== null) {
Expand All @@ -325,7 +344,7 @@ function mapResult(
}
if (result === null) throw new GenerationApiError('完成任务缺少 result', 200)
return expectation.type === 'complete_animation'
? mapActionResult(result, expectation)
? mapActionResult(result, expectation, frameCount)
: mapImageResult(result, expectation, expectedCandidateCount)
}

Expand Down Expand Up @@ -365,13 +384,6 @@ function validateInputPayload(
}
return candidateCount
}
const expectedFrameCount = 32
if (inputPayload.num_frames !== expectedFrameCount) {
throw new GenerationApiError(
`动作任务 input_payload.num_frames 必须为 ${expectedFrameCount}`,
200,
)
}
if (inputPayload.action_type !== expectation.actionType) {
throw new GenerationApiError('动作任务 input_payload.action_type 与请求不一致', 200)
}
Expand Down Expand Up @@ -400,12 +412,11 @@ function inferExpectation(dto: GenerationTaskDto): GenerationExpectation {
if (typeof actionType !== 'string' || !ACTION_TYPES.has(actionType)) {
throw new GenerationApiError('动作任务 input_payload.action_type 无效', 200)
}
if (dto.inputPayload.num_frames === 32) {
return direction === undefined
? { type: 'complete_animation', actionType }
: { type: 'complete_animation', actionType, direction }
}
throw new GenerationApiError('动作任务 input_payload.num_frames 无法映射到前端阶段', 200)
// 阶段由 task_type 与 action_type 定:character_action 在前端只对应"完整动画"这一个阶段。
// 帧数是产物参数不是阶段判据——拿它判,改一个动作的帧数就会让这类任务整个认不出来。
return direction === undefined
? { type: 'complete_animation', actionType }
: { type: 'complete_animation', actionType, direction }
}

function validateTaskIdentity(
Expand Down Expand Up @@ -450,7 +461,13 @@ function mapTask(
projectId: String(dto.projectId),
type: resolvedExpectation.type,
status: dto.status,
result: mapResult(dto.result, dto.status, resolvedExpectation, candidateCount),
result: mapResult(
dto.result,
dto.status,
resolvedExpectation,
candidateCount,
declaredFrameCount(dto.inputPayload, resolvedExpectation),
),
error: dto.errorMessage,
},
...(candidateCount === undefined ? {} : { candidateCount }),
Expand Down Expand Up @@ -530,14 +547,14 @@ function mapEvent<TType extends GenerationType>(
) {
throw new GenerationApiError('task_update 不属于当前项目', 200)
}
const candidateCount =
const inputPayload =
value.input_payload === undefined
? undefined
: dtoNullableRecord(value.input_payload, 'input_payload')
const candidateCount =
inputPayload === undefined
? expectedCandidateCount
: validateInputPayload(
dtoNullableRecord(value.input_payload, 'input_payload'),
expectation,
expectedCandidateCount,
)
: validateInputPayload(inputPayload, expectation, expectedCandidateCount)
const status = eventStatus(value, eventName)
const result = value.result === undefined ? null : dtoNullableRecord(value.result, 'result')
const error =
Expand All @@ -549,7 +566,13 @@ function mapEvent<TType extends GenerationType>(
taskId: String(taskId),
type: expectation.type,
status,
result: mapResult(result, status, expectation, candidateCount),
result: mapResult(
result,
status,
expectation,
candidateCount,
inputPayload === undefined ? undefined : declaredFrameCount(inputPayload, expectation),
),
error,
}
}
Expand Down Expand Up @@ -619,7 +642,9 @@ export function createGenerationApis(config: GenerationApiConfig): GenerationApi
...(input.actionType === 'custom' ? { loop: input.loop ?? false } : {}),
reference_video_url: null,
reference_image_urls: referenceImageUrls,
num_frames: 32,
// 不发帧数:哪种动作出多少帧是后端按 action_type 定的约定,前端发一个数就是
// 第二份约定,两边分叉时任务照跑、帧照出,没有一处会红。

// 后端拿 outfit_id 在场与否当三渲二的唯一判据(#122),所以它同时是"路线选择"
// 本身,不只是一个标识。无条件发送会让建过 3D 资产的造型点"视频裁剪"也走三渲二,
// 画风、成本、生成语义全被静默改掉,故只在用户真选了三渲二时发。
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/workflow-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function generateCharacter() {
- `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。
- Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。
- 新增 Action 一次创建动作首帧、动作生成方式、完整动画和审核四个 node,不会遗漏路线选择或用数组位置猜关系。
- 动作首帧按项目的真实源方向分别调用图片 Generation,每个方向默认生成三张候选,也可按调用场景在 1–4 张间调整;用户为全部真实方向各确认一张后,完整动画节点才使用对应首帧调用 32 帧动作 Generation。可水平镜像的方向只保存关系,不创建重复任务。
- 动作首帧按项目的真实源方向分别调用图片 Generation,每个方向默认生成三张候选,也可按调用场景在 1–4 张间调整;用户为全部真实方向各确认一张后,完整动画节点才使用对应首帧调用动作 Generation,帧数由后端按动作类型定,前端不传。可水平镜像的方向只保存关系,不创建重复任务。
- Controller 方法与后端 Generation、WorkflowRun node 使用同一概念名:`characterTemplate`、`firstFrame`、`completeAnimation` 和 `review`,不再为同一概念保留另一套叫法。
- 当前视频裁剪路线继续调用既有 Generation;3D 转 2D 选择会随 WorkflowRun 落库,但接口提供前明确阻止生成。
- Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。
Expand Down
Loading
Loading