diff --git a/backend/packages/app/src/windup_app/server/orchestrator/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py index 25082871..5fdb62b9 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/model.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -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): """生成任务状态。""" @@ -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"从这里起就再也分不开了。 @@ -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 模块)------------------ diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index 04339291..939abd90 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -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)─────────────────────────────────── # 这个动作是否循环播放。不给则编排层兜成一次性,也不按描述文字猜 —— 两个方向的代价 # 不对称:一次性动作被当成循环会让末帧接回首帧抽搐、产物不可用,反之只是不无缝闭环、 diff --git a/backend/packages/app/src/windup_app/worker/handlers.py b/backend/packages/app/src/windup_app/worker/handlers.py index 01eedb4a..53668cd7 100644 --- a/backend/packages/app/src/windup_app/worker/handlers.py +++ b/backend/packages/app/src/windup_app/worker/handlers.py @@ -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"), diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py index 9428db8b..b360b2e9 100644 --- a/backend/tests/test_generation_api.py +++ b/backend/tests/test_generation_api.py @@ -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) diff --git a/backend/tests/test_mq_worker.py b/backend/tests/test_mq_worker.py index 59013450..8ccae794 100644 --- a/backend/tests/test_mq_worker.py +++ b/backend/tests/test_mq_worker.py @@ -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 diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index f6b46ec1..191a8fec 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -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({ @@ -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', }) @@ -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( diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts index ec2932b0..2d39d9dc 100644 --- a/frontend/src/entities/generation/api.ts +++ b/frontend/src/entities/generation/api.ts @@ -246,9 +246,26 @@ function mapImageResult( : { type: expectation.type, direction: expectation.direction, images } } +/** + * 任务声明的帧数。哪种动作出多少帧由后端定,前端读回来当结果帧数的判据—— + * 在前端也写一个数就是第二份约定,与后端分叉时两边都不会报错。 + */ +function declaredFrameCount( + inputPayload: Record | 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, expectation: Extract, + frameCount: number | undefined, ): GenerationResult { if (result.type !== 'character_action') { throw new GenerationApiError('完整动画结果 type 无效', 200) @@ -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) } @@ -316,6 +334,7 @@ function mapResult( status: TaskStatus, expectation: GenerationExpectation, expectedCandidateCount?: ImageCandidateCount, + frameCount?: number, ): GenerationResult | null { if (status !== 'completed') { if (result !== null) { @@ -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) } @@ -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) } @@ -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( @@ -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 }), @@ -530,14 +547,14 @@ function mapEvent( ) { 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 = @@ -549,7 +566,13 @@ function mapEvent( 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, } } @@ -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 资产的造型点"视频裁剪"也走三渲二, // 画风、成本、生成语义全被静默改掉,故只在用户真选了三渲二时发。 diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index 84c75ab9..b17a1a70 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -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` 写回;节点重做后,旧任务的迟到结果会被丢弃。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 6704a548..c7583670 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -3049,7 +3049,7 @@ describe('WorkflowController', () => { expect(generation.apis.create).toHaveBeenCalledTimes(1) }) - it('完整动画必须是 32 帧,完成后只解锁自己的审核节点', async () => { + it('完整动画完成后只解锁自己的审核节点', async () => { const run = createRun([ ...completedCharacterNodes(), firstFrameNode({ @@ -3209,7 +3209,7 @@ describe('WorkflowController', () => { }) }) - it('完整动画节点拒绝帧数不足的结果', async () => { + it('完整动画节点拒绝没有帧的结果', async () => { const run = createRun([ ...completedCharacterNodes(), firstFrameNode({ status: 'passed', phase: 'completed', selectedFirstFrameUrl: 'first.png' }), @@ -3233,7 +3233,7 @@ describe('WorkflowController', () => { status: 'completed', result: { type: 'complete_animation', - frames: [{ index: 0, url: 'frame.png', durationMs: 80 }], + frames: [], }, error: null, }, @@ -3241,7 +3241,7 @@ describe('WorkflowController', () => { expect(controller.getWorkflow().nodes[4]).toMatchObject({ status: 'failed', - error: '完整动画应为 32 帧,实际为 1 帧', + error: '完整动画结果没有帧', }) }) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 9d998ef9..f0bbaedc 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -35,7 +35,6 @@ import { WorkflowRunConflictError, } from '@/entities' -const COMPLETE_ANIMATION_FRAME_COUNT = 32 const PROJECT_NAME_MAX_LENGTH = 20 const QUICK_START_PROJECT_NAME_ATTEMPTS = 100 @@ -1894,9 +1893,9 @@ function generationResultError(node: WorkflowNode, generation: Generation): stri ) { return '完整动画结果格式无效' } - return generation.result.frames.length === COMPLETE_ANIMATION_FRAME_COUNT - ? null - : `完整动画应为 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际为 ${generation.result.frames.length} 帧` + // 帧数是否合乎该动作类型的约定,由 generation 适配器按任务声明的 num_frames 判; + // 这里再写一个数就是第二份约定,各动作帧数不同时它会把合规结果判成失败。 + return generation.result.frames.length > 0 ? null : '完整动画结果没有帧' } return '当前节点不能绑定生成结果' diff --git a/openapi.json b/openapi.json index 053c9fbe..152b54a5 100644 --- a/openapi.json +++ b/openapi.json @@ -232,11 +232,17 @@ "title": "Loop" }, "num_frames": { - "default": 32, - "maximum": 64.0, - "minimum": 1.0, - "title": "Num Frames", - "type": "integer" + "anyOf": [ + { + "maximum": 64.0, + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Num Frames" }, "outfit_id": { "anyOf": [