diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index 2f194e8f..ff453e7d 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -25,8 +25,9 @@ CharacterGeneratorPort, GeneratedAction, ProgressPort, + SequenceGeometry, ) -from windup_ai_engine.postprocess import align_bottom_center, frame_durations +from windup_ai_engine.postprocess import FOOT_LINE, align_bottom_center, frame_durations from windup_ai_engine.prompt import PROMPT_VERSION from windup_ai_engine.slicing import ( dead_frame_indices, @@ -198,6 +199,7 @@ def _finish( return GeneratedAction( frames=[_png(im) for im in aligned], durations=frame_durations(action.action.value, len(aligned)), + geometry=_geometry_of(aligned[0], canvas), quality=quality, prompt_version=PROMPT_VERSION, ) @@ -263,3 +265,19 @@ def _lastmile( return align_bottom_center(imgs, ref_height=ref) cw, ch = canvas return align_bottom_center(imgs, cell=cw, cell_h=ch, ref_height=ref) + + +def _geometry_of(frame: Image.Image, canvas: tuple[int, int] | None) -> SequenceGeometry: + """交付帧的落位几何。取自对齐那一步用的同一组常量,不另立一份。 + + ``canvas`` 为 None 时 ``_lastmile`` 走 ``align_bottom_center`` 的默认 cell, + 所以这里也回落到实际交付帧的尺寸,而不是把 CELL 再抄一遍。 + """ + w, h = canvas if canvas else frame.size + return SequenceGeometry( + canvas_w=w, + canvas_h=h, + anchor_x=0.5, # align_bottom_center 横向恒居中 + anchor_y=FOOT_LINE, + foot_y=int(h * FOOT_LINE), + ) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index 45d8dd89..30f0cd61 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -233,6 +233,22 @@ class ActionQuality: """ +@dataclass(frozen=True) +class SequenceGeometry: + """一组交付帧的落位几何:主体锚点与脚线,取自对齐那一步的实参。 + + ``anchor`` 用左上原点、y 轴向下的 0-1 归一化坐标,与前端导出契约同一口径; + ``foot_y`` 是同一条线的像素值,一并给出是因为消费方要按画布像素画,而 + ``int(canvas_h * anchor_y)`` 的取整方式不该由每个消费方各自决定。 + """ + + canvas_w: int + canvas_h: int + anchor_x: float + anchor_y: float + foot_y: int + + @dataclass class GeneratedAction: """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长 + 成色 + 提示词版本。 @@ -242,6 +258,10 @@ class GeneratedAction: """ frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + # 交付帧的落位几何。**报出来而不是让消费方按常数推**:脚线比例、画布尺寸都是 + # 对齐那一步的实参,消费方抄一份常数过去就是第二真相源 —— 前端 export 正是这么 + # 抄的(``FOOT_LINE_RATIO = 0.92``),而这边改了 FOOT_LINE 那边不会跟着变。 + geometry: SequenceGeometry | None = field(default=None, kw_only=True) # 播放时序的**唯一**真相源。曾另有一个 fps 字段抄自入参,与本字段互相矛盾: # fps=20 宣称 50ms/帧,而 walk 这里给的是 125ms/帧 —— 同一段素材两个播放速度, # 取哪个看消费方心情(2026-08-10 机器审 P2)。逐帧 ms 严格更能表达(关键帧定格), diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py index e69f0ec7..bd919c7e 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py @@ -11,7 +11,7 @@ pixelate_frames, to_pixel_art, ) -from .pack import align_bottom_center, save_gif, sprite_sheet +from .pack import FOOT_LINE, align_bottom_center, save_gif, sprite_sheet __all__ = [ "to_pixel_art", @@ -23,6 +23,7 @@ "frame_durations", "DEFAULT_FPS_MS", "align_bottom_center", + "FOOT_LINE", "sprite_sheet", "save_gif", ] diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index 47fb6b16..7752594a 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -406,6 +406,17 @@ def _produce_action( "quality": dataclasses.asdict(generated.quality), "prompt_version": generated.prompt_version, } + # 落位几何随产物一起交出:消费方要把帧画到画布上、判角色有没有站在地上, + # 而这条线的比例是对齐那一步的实参。前端此前抄了一份 0.92 自己算 —— 两份 + # 常数只要有一次不同步,角色就不站在地上,而没有任何一道会红。 + if generated.geometry is not None: + g = generated.geometry + result["geometry"] = { + "canvas_width": g.canvas_w, + "canvas_height": g.canvas_h, + "anchor": {"x": g.anchor_x, "y": g.anchor_y}, + "foot_y": g.foot_y, + } # master 为 None 时 review 按"没判"返回 None(三渲二路线没有可比的参照)。 decision = quality_gate.review( self._get_judge(), checked, master, _judged_action(input) 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..fa356436 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/model.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -133,7 +133,9 @@ class CharacterActionOutput: 前端拿到后写入 ``character_data.outfits[].actions[]``: ``action_type`` → ``CharacterAction.type``, - ``frames`` → ``CharacterAction.frames[]``。 + ``frames`` + ``direction`` → ``CharacterAction.sequences[]``(一个方向一条, + 镜像方向按 ``CharacterActionSequence`` 的校验只存来源关系、不存帧), + ``geometry`` → 导出契约的 ``anchor`` / ``footY``。 ``quality`` / ``prompt_version`` 是引擎产出成色的账本(``ai_engine.ports.ActionQuality`` 的原样转录 + 提示词版本),不参与前端回填、只落库供后续对比——本层不据此判成败, @@ -153,6 +155,9 @@ class CharacterActionOutput: quality: dict | None = None prompt_version: str | None = None direction: ActionDirection = ActionDirection.EAST + # 交付帧的落位几何(画布尺寸、主体锚点、脚线像素)。``None`` = 引擎没给, + # 不是"用默认值" —— 消费方要能区分这两者,才不会把缺省当成实测。 + geometry: dict | None = None # -- 任务记录 ------------------------------------------------------------ diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index d09f43c5..10b7aa00 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -600,3 +600,25 @@ def test_after_approval_it_proceeds_and_reuses_the_stored_model(tmp_path): gate.approve(OUTFIT) assert builder.ensure(OUTFIT, _png(), _NullProgress()) == b"RIGGED-bytes" assert (m.calls, r.calls) == (1, 1) + + +def test_generated_action_reports_the_alignment_geometry_instead_of_a_constant(): + """交付几何由引擎报出,不让消费方按常数推。 + + 前端导出契约此前自带一份 ``FOOT_LINE_RATIO = 0.92`` 算 anchor 与 footY。 + 两份常数只要有一次不同步,角色就不站在地上,而帧数、时长、成色全都正常 —— + 没有任何一道会红。所以这条钉的是"报出来了",而且报的值必须来自 + ``postprocess.FOOT_LINE`` 本身,不是用例里再抄一遍的字面量。 + """ + from windup_ai_engine.postprocess import FOOT_LINE + + canvas = (256, 320) + out = _real_generator(_FakeRenderer()).generate_rendered( + _card(), _spec(), b"RIGGED", _NullProgress(), canvas=canvas, + ) + g = out.geometry + assert g is not None, "几何必须报出来,None 表示引擎没给" + assert (g.canvas_w, g.canvas_h) == canvas + assert g.anchor_x == 0.5 + assert g.anchor_y == FOOT_LINE + assert g.foot_y == int(canvas[1] * FOOT_LINE) diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts index ec2932b0..7188a5c1 100644 --- a/frontend/src/entities/generation/api.ts +++ b/frontend/src/entities/generation/api.ts @@ -21,6 +21,7 @@ import type { GenerationResult, GenerationType, ImageCandidateCount, + SequenceGeometry, TaskStatus, } from '.' import { isActionDirection, type ActionDirection } from '@/entities/character/directions' @@ -302,15 +303,52 @@ function mapActionResult( throw new GenerationApiError('动作帧 index 必须从 0 开始连续排列', 200) } } + const geometry = actionGeometry(result.geometry) const mapped = { type: 'complete_animation', frames: orderedFrames, + ...(geometry === undefined ? {} : { geometry }), } as const return expectation.direction === undefined ? mapped : { ...mapped, direction: expectation.direction } } +/** + * 解析交付帧的落位几何。缺失返回 undefined —— 旧任务没有这一段,而"没给"与 + * "给了默认值"必须能被消费方区分开:把缺省读成实测,角色不站在地上时没有一处会报错。 + * 给了就按结构严格校验,半个几何比没有更糟。 + */ +function actionGeometry(value: unknown): SequenceGeometry | undefined { + if (value === undefined || value === null) return undefined + if (!isRecord(value)) throw new GenerationApiError('完整动画结果 geometry 不是对象', 200) + const anchor = value.anchor + if (!isRecord(anchor)) throw new GenerationApiError('完整动画结果 geometry.anchor 无效', 200) + const unit = (raw: unknown, field: string) => { + if (!Number.isFinite(raw) || (raw as number) < 0 || (raw as number) > 1) { + throw new GenerationApiError(`完整动画结果 ${field} 必须是 0-1 归一化值`, 200) + } + return raw as number + } + const positive = (raw: unknown, field: string) => { + if (!Number.isSafeInteger(raw) || (raw as number) <= 0) { + throw new GenerationApiError(`完整动画结果 ${field} 无效`, 200) + } + return raw as number + } + const canvasHeight = positive(value.canvas_height, 'geometry.canvas_height') + const footY = value.foot_y + if (!Number.isSafeInteger(footY) || (footY as number) < 0 || (footY as number) > canvasHeight) { + throw new GenerationApiError('完整动画结果 geometry.foot_y 超出画布', 200) + } + return { + canvasWidth: positive(value.canvas_width, 'geometry.canvas_width'), + canvasHeight, + anchor: { x: unit(anchor.x, 'geometry.anchor.x'), y: unit(anchor.y, 'geometry.anchor.y') }, + footY: footY as number, + } +} + function mapResult( result: Record | null, status: TaskStatus, diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 242bdabc..e9671ee8 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -124,10 +124,22 @@ export interface FirstFrameGenerationResult { images: readonly GeneratedImage[] } +/** 交付帧的落位几何,由后端按对齐时的实参报出。 */ +export interface SequenceGeometry { + canvasWidth: number + canvasHeight: number + /** 左上原点、y 轴向下的 0-1 归一化坐标。 */ + anchor: { x: number; y: number } + /** 脚底线距画布顶部的像素值。 */ + footY: number +} + export interface CompleteAnimationGenerationResult { type: 'complete_animation' direction?: ActionDirection frames: readonly GeneratedFrame[] + /** 旧任务没有这一段;缺失时消费方不能当成"用默认值",只能明示回落。 */ + geometry?: SequenceGeometry } export type GenerationResult = diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index ce8cb1bd..ea74d900 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -99,6 +99,7 @@ export type { GenerationResultFor, GenerationType, ImageCandidateCount, + SequenceGeometry, TaskStatus, } from './generation' export type { GenerationApiConfig, GenerationTransport } from './generation/api' diff --git a/frontend/src/features/export-package/progressive-export.test.ts b/frontend/src/features/export-package/progressive-export.test.ts index d06b5e05..99f0e99b 100644 --- a/frontend/src/features/export-package/progressive-export.test.ts +++ b/frontend/src/features/export-package/progressive-export.test.ts @@ -85,6 +85,44 @@ const run: WorkflowRun = { ], } +/** 一条走到"完整动画已生成、尚未发布"的流程,几何相关用例共用。 */ +function actionRunFixture(base: WorkflowRun): WorkflowRun { + return { + ...base, + nodes: [ + ...base.nodes, + { + id: 'walk-method', + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['walk-first'], + generations: [], + error: null, + method: 'video-cropping', + }, + { + id: 'walk-full', + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['walk-method'], + generations: [{ taskId: 'generation-full', role: 'complete_animation' }], + error: null, + }, + { + id: 'walk-review', + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: ['walk-full'], + generations: [], + error: null, + }, + ], + } +} + describe('createProgressiveExportModel', () => { it('拒绝把其它 WorkflowRun 的完成度拼到当前角色', () => { expect(() => @@ -241,6 +279,66 @@ describe('createProgressiveExportModel', () => { }) }) + it('落位几何取后端报的那份,而不是前端自己按 0.92 算', () => { + const generation = { + id: 'generation-full', + projectId: project.id, + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + frames: [{ index: 0, url: '/walk-0.png', durationMs: 100 }], + // 故意与 0.92 不同:若前端还在自己算,这条就会读出 spriteHeight*0.92 + geometry: { + canvasWidth: project.spriteSize.width, + canvasHeight: project.spriteSize.height, + anchor: { x: 0.5, y: 0.8 }, + footY: 32, + }, + }, + } satisfies Generation<'complete_animation'> + + const model = createProgressiveExportModel({ + project, + character, + outfitId: 'outfit-1', + run: actionRunFixture(run), + generations: [generation], + }) + + expect(model.actions[0]?.sequences[0]).toMatchObject({ + anchor: { x: 0.5, y: 0.8 }, + footY: 32, + }) + }) + + it('后端没报几何时明示回落,不静默给 0', () => { + const generation = { + id: 'generation-full', + projectId: project.id, + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + frames: [{ index: 0, url: '/walk-0.png', durationMs: 100 }], + }, + } satisfies Generation<'complete_animation'> + + const model = createProgressiveExportModel({ + project, + character, + outfitId: 'outfit-1', + run: actionRunFixture(run), + generations: [generation], + }) + + const sequence = model.actions[0]?.sequences[0] + expect(sequence?.anchor).toEqual({ x: 0.5, y: 0.92 }) + expect(sequence?.footY).toBe(Math.trunc(project.spriteSize.height * 0.92)) + }) + it('同名但不同 ID 的已发布与生成中动作不会互相覆盖', () => { const withPublishedAction: Character = { ...character, diff --git a/frontend/src/features/export-package/progressive-export.ts b/frontend/src/features/export-package/progressive-export.ts index a489c351..e742c0d5 100644 --- a/frontend/src/features/export-package/progressive-export.ts +++ b/frontend/src/features/export-package/progressive-export.ts @@ -4,6 +4,7 @@ import type { Frame, Generation, Project, + SequenceGeometry, WorkflowActionInput, WorkflowRun, } from '@/entities' @@ -16,7 +17,27 @@ import type { ExportPlaytest, } from './model' -const FOOT_LINE_RATIO = 0.92 +/** + * 后端没报落位几何时的回落值(早于 geometry 上线的资产)。 + * + * 这个数与后端 `postprocess.pack.FOOT_LINE` 是同一条线,抄一份在这里就是第二真相源 —— + * 所以只在缺 geometry 时用,能拿到就一律用后端报的那份。两份不同步的后果是角色不站在 + * 地上,而帧数、时长、成色全都正常,没有任何一道会红。 + */ +const FALLBACK_FOOT_LINE_RATIO = 0.92 + +function sequenceGeometry( + geometry: SequenceGeometry | undefined, + spriteHeight: number, +): { anchor: { x: number; y: number }; footY: number } { + if (geometry) { + return { anchor: geometry.anchor, footY: geometry.footY } + } + return { + anchor: { x: 0.5, y: FALLBACK_FOOT_LINE_RATIO }, + footY: Math.trunc(spriteHeight * FALLBACK_FOOT_LINE_RATIO), + } +} export interface CreateProgressiveExportModelInput { project: Project @@ -63,8 +84,7 @@ function exportAction(action: Action, project: Project): ExportAction { direction: 'default', expectedFrameCount: action.frameCount, loop: action.loop, - anchor: { x: 0.5, y: FOOT_LINE_RATIO }, - footY: Math.trunc(project.spriteSize.height * FOOT_LINE_RATIO), + ...sequenceGeometry(undefined, project.spriteSize.height), qualityStatus: 'passed', frames: exportFrames(action), }, @@ -186,8 +206,7 @@ function generatedActions( direction: 'default', expectedFrameCount: frames.length, loop: true, - anchor: { x: 0.5, y: FOOT_LINE_RATIO }, - footY: Math.trunc(project.spriteSize.height * FOOT_LINE_RATIO), + ...sequenceGeometry(generation.result.geometry, project.spriteSize.height), qualityStatus: review?.status === 'passed' ? 'passed' : 'pending', frames, },