Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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),
)
20 changes: 20 additions & 0 deletions backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长 + 成色 + 提示词版本。
Expand All @@ -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 严格更能表达(关键帧定格),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -23,6 +23,7 @@
"frame_durations",
"DEFAULT_FPS_MS",
"align_bottom_center",
"FOOT_LINE",
"sprite_sheet",
"save_gif",
]
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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``
的原样转录 + 提示词版本),不参与前端回填、只落库供后续对比——本层不据此判成败,
Expand All @@ -153,6 +155,9 @@ class CharacterActionOutput:
quality: dict | None = None
prompt_version: str | None = None
direction: ActionDirection = ActionDirection.EAST
# 交付帧的落位几何(画布尺寸、主体锚点、脚线像素)。``None`` = 引擎没给,
# 不是"用默认值" —— 消费方要能区分这两者,才不会把缺省当成实测。
geometry: dict | None = None


# -- 任务记录 ------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/test_render3d_route_and_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
38 changes: 38 additions & 0 deletions frontend/src/entities/generation/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
GenerationResult,
GenerationType,
ImageCandidateCount,
SequenceGeometry,
TaskStatus,
} from '.'
import { isActionDirection, type ActionDirection } from '@/entities/character/directions'
Expand Down Expand Up @@ -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<string, unknown> | null,
status: TaskStatus,
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/entities/generation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export type {
GenerationResultFor,
GenerationType,
ImageCandidateCount,
SequenceGeometry,
TaskStatus,
} from './generation'
export type { GenerationApiConfig, GenerationTransport } from './generation/api'
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/features/export-package/progressive-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading