diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py new file mode 100644 index 00000000..52434b34 --- /dev/null +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -0,0 +1,21 @@ +from windup_common.models.character import ( + DEFAULT_N_FRAMES, + ActionSpec, + ActionType, + CharacterCard, + CharacterView, + Facing, + GenRoute, + Stylize, +) + +__all__ = [ + "ActionType", + "GenRoute", + "Facing", + "CharacterView", + "Stylize", + "DEFAULT_N_FRAMES", + "CharacterCard", + "ActionSpec", +] diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py new file mode 100644 index 00000000..3d4cefdb --- /dev/null +++ b/backend/packages/common/src/windup_common/models/character.py @@ -0,0 +1,206 @@ +"""共享 DTO —— 跨层契约(common,无内部依赖)。 + +产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举。 +仅定义结构,不含行为。ai_engine / app 均依赖此。 + +**为什么受限取值一律用枚举而不是裸 str(2026-08-08 收紧)**: +``facing`` 承载的是一条实测挣得的硬约束——"提示词朝向必须与母版朝向一致" +(见 ai_engine.master_prep:给正面母版喂侧走词,模型会靠转身调和图文矛盾)。 +它此前是裸 str、合法值只写在行尾注释里:写成 "Side" / "sidee" 不报错、不告警, +调用链一路放行,几分钟和一次真金白银的视频调用之后才在画面上看出角色转了身。 +枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时 ValidationError", +成本从一次付费生成降到零。``loop`` / ``stylize`` / ``view`` 同理。 +""" +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +# 未知字段一律报错(pydantic 默认是 extra="ignore",静默丢弃)。理由与本文件用枚举取代裸 +# str 完全同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)`(少个 s) +# 在 ignore 下不报错、不生效,调用方以为要了 16 帧、实际拿到默认 8 帧;`CharacterCard( +# palette=...)` 这类已删字段同理会被静默吞掉。forbid 让这些当场变成 ValidationError。 +_STRICT = ConfigDict(extra="forbid") + + +def _without(data: dict[str, Any], key: str) -> dict[str, Any]: + """去掉某键的浅拷贝(不改调用方传进来的 dict —— before 校验器拿到的是原对象)。""" + return {k: v for k, v in data.items() if k != key} + + +class ActionType(str, Enum): + """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。 + + **本枚举是"引擎能生成的动作",不是"API 能接收的动作",两者刻意分离。** 入口侧的 + ``windup_app.server.orchestrator.model.ActionType`` 另有 ``custom``,且少 run / + jump / hit;跨越两者靠编排层的显式适配函数 ``_to_engine_action``,它对引擎没有路线 + 的类型抛带原因的错误,而不是让请求走到一半失败。 + + 为什么不直接把 ``custom`` 加进来:``ROUTE_MATRIX`` 没有它的分流,加了成员等于接收 + 一个我们无法履约的请求——与 :class:`GenRoute` docstring 里那条是同一原则。API 入口 + 的枚举保持不变,所以对既有调用方的兼容性不受影响。 + """ + + IDLE = "idle" + WALK = "walk" + RUN = "run" + JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases) + ATTACK = "attack" # slash / thrust / dash 归此 + HIT = "hit" + + +class GenRoute(str, Enum): + """生成路线 —— 实测挣得的分流依据(见 ai_engine.strategy 层 docstring)。 + + **只列有实现的路线。** 没有实现的枚举值等于死代码:它会让调用方以为该能力存在, + 而分流到它只能得到运行时错误。未来路线(如三渲二渲染出帧)的契约需求记在 Issue 里 + (见 #81 #122),随实现一起加成员 —— 枚举加成员是纯加法,不构成破坏性变更。 + """ + + VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿) + PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑) + + +class Facing(str, Enum): + """提示词朝向 —— **必须与母版朝向一致**(硬约束,见 ai_engine.master_prep)。 + + - ``SIDE``:横版侧视,角色朝画面右侧行进(母版也须朝侧向)。 + - ``FRONT``:身体正对观者(俯视与 2.5D 都归此)。 + + 与 :class:`CharacterView` 的对应关系:SIDE→SIDE;TOP_DOWN / ISOMETRIC→FRONT。 + 两者不合并成一个枚举:view 是项目级美术视角(对应 ``Project.character_perspective``, + 决定母版怎么画),facing 是提示词模板的二选一(只区分"看得到侧面"和"正对镜头")。 + """ + + SIDE = "side" + FRONT = "front" + + +class CharacterView(str, Enum): + """角色美术视角 —— 与 ``Project.character_perspective``(1/2/3)一一对应。 + + 字符串取值与前端契约(frontend/API_CONTRACT.md 的映射表)逐字一致, + 免得将来做 int ↔ str 映射时再造一套别名(如 topdown / top_down / top-down 三写)。 + """ + + SIDE = "side" # perspective=1 横版 + TOP_DOWN = "top-down" # perspective=2 俯视 + ISOMETRIC = "isometric" # perspective=3 2.5D + + +class Stylize(str, Enum): + """风格化模式。 + + ``PIXEL``=像素化(原生像素角色 i2v 后复原像素感);``NONE``=保留 i2v 的插画质感。 + 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由角色画风决定。 + """ + + PIXEL = "pixel" + NONE = "none" + + +# 视频路线未指定帧数时的默认出帧数。原先以 `action.n_frames or 8` 的形式藏在 +# strategy.concrete 里,是"契约的缺省值写在实现里"——换个 strategy 就换个默认值。 +DEFAULT_N_FRAMES = 8 + + +class CharacterCard(BaseModel): + """角色卡 —— 一致性主键 + 资产库基础(产品核心实体)。 + + 注意:视频路线**不读本模型的任何字段**,角色身份由母版图像承载。 + 详见 ``windup_ai_engine.ports.CharacterGeneratorPort`` 的 docstring。 + """ + + model_config = _STRICT + + name: str + desc: str # 身份描述(喂模型锁一致性) + view: CharacterView = CharacterView.SIDE + master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) + version: str = "v1" + + # 注:曾有 `palette: str = ""`。2026-08-08 删除,理由是它会变成"看起来生效、实则被 + # 忽略"的第二真相源:真正锁色的色板由 postprocess.master_pixel_spec 从母版像素里量出来 + # (ndarray,喂给 _snap_to_palette),而这个字段零消费方、无格式约定。调用方填了 + # "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,不报错也不生效——正是本项目最忌讳的 + # "看起来成功的错结果"。将来若要支持用户指定色板,连同消费它的代码一起加回,并用结构化 + # 类型(如 list[str] 且校验 hex)而不是自由 str。 + + +class ActionSpec(BaseModel): + """动作规格 —— 帧数 / 逐帧姿势 / 风格化 / 朝向。 + + **播放时序的唯一真相源是出参的 ``durations``(逐帧 ms),不是入参的帧率。** + 这里曾有 ``fps`` 与 ``loop`` 两个字段,都已删除,理由与 :class:`GenRoute` + docstring 里那条一致——没有实现的取值等于死代码,它让调用方以为该能力存在: + + - ``fps``:零写入方(编排层构造 ActionSpec 时从不传),而 ``postprocess. + frame_durations`` 按动作查表、**根本不看它**。留着的后果是 ``fps=20`` 宣称 + 50ms/帧、walk 实际返回 125ms/帧,两个字段描述同一段素材的不同播放速度。 + - ``loop``:零消费方。闭环行为写死在 ``slicing.pick_cycle`` 里——循环类动作 + 一律抽单周期闭环,传 ``pingpong`` / ``none`` 不改变任何产出。调用方可以为一段 + 往返动画付费、拿到一段线性循环,正是本项目最忌讳的"静默成功"。 + + 真要支持 pingpong,连同 ``pick_cycle`` 的分支、出参的时序契约一起加回。 + """ + + model_config = _STRICT + + action: ActionType + + # 出帧数。**显式字段,不再由 len(poses) 推导**:视频路线根本不读 poses(见 + # strategy.concrete.VideoFrameStrategy),推导意味着"想要 16 帧就得先编 16 条用不上的 + # 姿势描述",而那 16 条描述读者会以为真的进了提示词。 + n_frames: int = Field(default=DEFAULT_N_FRAMES, ge=1) + + # 逐帧路线专用:每帧一条姿势描述,只有 PER_FRAME 会真的读它。 + poses: list[str] = Field(default_factory=list) + + stylize: Stylize = Stylize.PIXEL + # 两个下界抄的是实现里已经存在的真实取值域,把"实现悄悄纠正入参"提前成入参报错: + # pixel_h → postprocess.to_pixel_art 对 <1 直接 raise,契约没理由比实现更宽松; + # palette_size → 同处 `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2, + # 于是"我要 1 色"拿到 2 色且无任何提示 —— 正是本项目最忌讳的静默纠正。 + pixel_h: int = Field(default=100, ge=1) # 像素化目标高(角色像素行数) + palette_size: int = Field(default=32, ge=2) # 色板色数(1 色的像素画不存在) + # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective)。 + facing: Facing = Facing.SIDE + + @model_validator(mode="before") + @classmethod + def _reconcile_n_frames_with_poses(cls, data: Any) -> Any: + """兼容旧调用方(只传 poses),并让 n_frames 与 poses 打架时**炸掉而不是猜**。 + + - 只给 poses:帧数仍取 len(poses),旧调用方零改动。 + - 两个都给且不等:抛错。此时规格自相矛盾,引擎无法知道调用方要 16 帧还是 12 帧 + (common 层看不到 ROUTE_MATRIX,判不出走哪条路线),猜一个的代价是静默出错帧数。 + 走视频路线的调用方本就不该传 poses,删掉即可。 + - 显式 ``None`` 一律等同"没传"(两条分支一致):调用方写 + ``n_frames=form.get("n_frames")`` 时 None 表示"未指定",该走缺省,不该炸。 + """ + if not isinstance(data, dict): # model_validate(实例) 等非 dict 入参原样放行 + return data + n = data.get("n_frames") + poses = data.get("poses") + if n is None: + # 有 poses 就回退到 len(poses),没有则删键让字段缺省值(DEFAULT_N_FRAMES)生效。 + # 不能原样留 None:`n_frames: int` 会报 "Input should be a valid integer", + # 于是"显式 None"在有/无 poses 两种情况下行为不一致(一个回退、一个报错)。 + return {**data, "n_frames": len(poses)} if poses else _without(data, "n_frames") + if not poses: + return data + # 先按 int 归一再比:pydantic 之后会把 JSON 里的 "2" 收成 2,而这里若直接 `n != len` + # 比较,``{"n_frames": "2", "poses": ["a","b"]}`` 会得到自相矛盾的报错 + # 「n_frames=2 与 len(poses)=2 不一致」——把一次合法请求判成打架(2026-08-08 实测)。 + try: + n_int = int(n) + except (TypeError, ValueError): + return data # 类型本就不对 → 交给字段校验报正经的 int 错 + if n_int != len(poses): + raise ValueError( + f"n_frames={n_int} 与 len(poses)={len(poses)} 不一致;" + "逐帧路线要求两者相等,视频路线不该传 poses。" + ) + return data diff --git a/backend/tests/test_character_contract.py b/backend/tests/test_character_contract.py new file mode 100644 index 00000000..ebb53098 --- /dev/null +++ b/backend/tests/test_character_contract.py @@ -0,0 +1,253 @@ +"""跨层契约(windup_common.models.character)的类型约束。 + +本文件锁的不是"字段叫什么名",而是**一类错误必须在构造 ActionSpec / CharacterCard 时就炸**: +朝向拼错、帧数字段名打错、规格自相矛盾。这些错误以前一路放行到 i2v 调用之后才在画面上显形, +一次误判的成本 = 一次付费视频生成 + 人肉看片。 + +本文件只测 DTO 自身,不 import 上层包 —— 契约包要能独立验证。配套的实现侧断言 +("prompt 模板真的按 facing 选对"、"strategy 真的按 n_frames 出帧")随各自的实现 +分片走,契约合法不代表实现读对了。 +""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from windup_common.models import ( + DEFAULT_N_FRAMES, + ActionSpec, + ActionType, + CharacterCard, + CharacterView, + Facing, + Stylize, +) + + +# ── A1 受限取值:枚举,不是裸 str ──────────────────────────────────────────── + + +@pytest.mark.parametrize("bad", ["Side", "sidee", "SIDE", "left", "", None, 1]) +def test_facing_typo_is_rejected_at_construction(bad): + """朝向拼错必须当场炸。 + + 这条约束的分量:facing 决定用侧走词还是正面走词,而"提示词朝向必须与母版朝向一致" + 是三次实测挣得的硬前提(见 ai_engine.master_prep)。裸 str 时代 "Side" 一路放行, + 要等 i2v 出片、人眼看到角色转身才发现。 + """ + with pytest.raises(ValidationError): + ActionSpec(action=ActionType.WALK, facing=bad) + + +def test_legal_facing_string_is_coerced_to_enum_member(): + """合法字符串仍可传(旧调用方零改动),但落到模型里是枚举成员。""" + spec = ActionSpec(action=ActionType.WALK, facing="front") + assert spec.facing is Facing.FRONT + assert ActionSpec(action=ActionType.WALK).facing is Facing.SIDE + + +@pytest.mark.parametrize( + ("field", "bad", "good", "member"), + [ + ("stylize", "pixels", "none", Stylize.NONE), + ], +) +def test_action_spec_restricted_fields_reject_typos(field, bad, good, member): + with pytest.raises(ValidationError): + ActionSpec(action=ActionType.WALK, **{field: bad}) + assert getattr(ActionSpec(action=ActionType.WALK, **{field: good}), field) is member + + +def test_character_view_rejects_typos_and_matches_frontend_contract(): + """view 的取值与前端契约(frontend/API_CONTRACT.md:1 side / 2 top-down / 3 isometric) + 逐字一致,免得将来做 int↔str 映射时出现 topdown / top_down / top-down 三种写法。 + """ + assert {v.value for v in CharacterView} == {"side", "top-down", "isometric"} + with pytest.raises(ValidationError): + CharacterCard(name="n", desc="d", view="topdown") # 少了连字符 + assert CharacterCard(name="n", desc="d", view="top-down").view is CharacterView.TOP_DOWN + + +def test_character_card_default_view_is_a_legal_value(): + """默认值必须落在自己的取值集合里。 + + 改枚举前的默认是 ``view = "pseudo-side"`` —— 它连自己行尾注释写的 + "side / topdown / isometric" 都不在其中。任何 ``if card.view == "side"`` 的消费方 + 对每一个默认构造的角色卡都会走错分支,且不会有任何报错。 + """ + assert CharacterCard(name="n", desc="d").view in set(CharacterView) + + +def test_unknown_field_name_is_rejected_not_silently_dropped(): + """字段名打错要炸。pydantic 默认 extra="ignore" 会静默吞掉。 + + ``n_frame``(少个 s)在 ignore 下的后果和 facing 拼错同级:不报错、不生效, + 调用方以为点了 16 帧,实际拿到默认 8 帧的成片。 + """ + with pytest.raises(ValidationError): + ActionSpec(action=ActionType.WALK, n_frame=16) + with pytest.raises(ValidationError): + CharacterCard(name="n", desc="d", nmae="typo") + + +# ── A2 n_frames 是显式字段,不再由 len(poses) 推导 ────────────────────────── + + +def test_n_frames_is_explicit_and_needs_no_dummy_poses(): + """要 16 帧就写 16 —— 不必编 16 条视频路线根本不读的姿势描述。""" + spec = ActionSpec(action=ActionType.WALK, n_frames=16) + assert spec.n_frames == 16 + assert spec.poses == [] + + +def test_n_frames_defaults_to_the_contract_default(): + assert ActionSpec(action=ActionType.WALK).n_frames == DEFAULT_N_FRAMES == 8 + + +def test_n_frames_falls_back_to_len_poses_for_old_callers(): + """旧调用方只传 poses 时行为不变(兼容),包括显式传 None。""" + assert ActionSpec(action=ActionType.HIT, poses=["a", "b", "c"]).n_frames == 3 + assert ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=None).n_frames == 2 + + +def test_n_frames_and_poses_may_agree(): + assert ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=2).n_frames == 2 + + +def test_conflicting_n_frames_and_poses_raises_instead_of_picking_one(): + """规格自相矛盾时炸掉,不猜。 + + common 层看不到 ROUTE_MATRIX(分层约束),判不出这条 spec 走视频还是逐帧, + 因此"哪个字段说了算"无从判定。猜一个的代价是静默出错帧数的成片。 + """ + with pytest.raises(ValidationError, match="n_frames"): + ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=16) + + +@pytest.mark.parametrize("bad", [0, -1]) +def test_n_frames_must_be_at_least_one(bad): + """0 帧的 spec 不能进管线:付一次视频的钱、抽 0 帧、产出一个空动作。""" + with pytest.raises(ValidationError): + ActionSpec(action=ActionType.WALK, n_frames=bad) + + +def test_explicit_none_means_unspecified_with_or_without_poses(): + """``n_frames=None`` 两条分支行为一致 —— 都当"没指定"。 + + 调用方常写 ``n_frames=payload.get("n_frames")``。修之前:有 poses 时 None 回退到 + len(poses),没 poses 时 None 撞上 ``n_frames: int`` 直接 ValidationError —— + 同一个"未指定"在两种上下文里一个能用一个报错。 + """ + assert ActionSpec(action=ActionType.WALK, n_frames=None).n_frames == DEFAULT_N_FRAMES + assert ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=None).n_frames == 2 + + +def test_json_string_n_frames_agreeing_with_poses_is_not_a_conflict(): + """JSON 入参里 n_frames 是字符串 "2"、poses 两条 —— 这是一致的,不该报打架。 + + 修之前 before 校验器在 pydantic 收敛类型之前直接 ``"2" != 2``,于是抛出自相矛盾的 + 「n_frames=2 与 len(poses)=2 不一致」,把一次合法请求判成非法(2026-08-08 实测)。 + """ + spec = ActionSpec.model_validate({"action": "hit", "poses": ["a", "b"], "n_frames": "2"}) + assert spec.n_frames == 2 + + +def test_json_string_n_frames_conflicting_with_poses_still_raises(): + """收敛类型不等于放过打架 —— "16" vs 2 条 poses 仍要炸。""" + with pytest.raises(ValidationError, match="n_frames"): + ActionSpec.model_validate({"action": "hit", "poses": ["a", "b"], "n_frames": "16"}) + + +@pytest.mark.parametrize( + "payload", + [ + {"action": "walk", "n_frames": None}, # 走删键分支(_without) + {"action": "walk", "poses": ["a", "b"]}, # 走补键分支 + {"action": "walk", "poses": ["a"], "n_frames": 1}, + ], +) +def test_validator_does_not_mutate_the_callers_payload(payload): + """before 校验器拿到的是调用方那个 dict 本体,原地改它会污染调用方的数据。 + + 三个入参分别覆盖校验器的三条出口 —— 只测一条会漏:最初这里只传了 poses 那一条, + 于是"删键分支改成原地 pop"的变异全绿通过(2026-08-08 变异验证抓到)。 + """ + before = {k: (list(v) if isinstance(v, list) else v) for k, v in payload.items()} + ActionSpec.model_validate(payload) + assert payload == before + + +# ── 取值域:实现里已有的下界写进契约,别让实现悄悄纠正入参 ──────────────────── + + +@pytest.mark.parametrize( + ("field", "bad"), + [ + ("fps", 0), ("fps", -1), # 播放侧的除数,0 无合法语义 + ("pixel_h", 0), # postprocess.to_pixel_art 对 <1 本就 raise + ("palette_size", 1), # quantize(colors=max(2, …)) 会把 1 静默抬成 2 + ], +) +def test_numeric_fields_reject_values_the_implementation_would_silently_fix(field, bad): + with pytest.raises(ValidationError): + ActionSpec(action=ActionType.WALK, **{field: bad}) + + + + +# ── A3 palette 已删除 ─────────────────────────────────────────────────────── + + +def test_character_card_has_no_palette_field(): + """``palette: str`` 已删(2026-08-08)。 + + 删而不是"定清格式"的理由:真正锁色的色板由 postprocess.master_pixel_spec 从母版像素里 + 量出来(ndarray → _snap_to_palette),角色卡上再挂一个自由 str 就是同一件事的第二真相源, + 而且是更弱的那个 —— 零消费方。调用方填 "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板, + 不报错也不生效,正是本项目最忌讳的"看起来成功的错结果"。 + """ + assert "palette" not in CharacterCard.model_fields + + +def test_passing_palette_now_fails_loudly(): + """删字段要让旧调用方听得见响 —— extra="forbid" 保证它不是被静默丢弃。""" + with pytest.raises(ValidationError): + CharacterCard(name="n", desc="d", palette="#1a1a2e,#e94560") + + +# ── A4 fps 与 loop 已删除(2026-08-10,机器审 P2)───────────────────────────── + + +def test_action_spec_has_no_fps_or_loop_field(): + """两个字段都是"接了不履约"的入参,删而不是留着加注释。 + + - ``fps``:零写入方(编排层构造 ActionSpec 时从不传),而 postprocess.frame_durations + 按动作查表、根本不看它。留着的后果是同一段素材有两个互相矛盾的播放速度: + ``fps=20`` 宣称 50ms/帧,walk 实际给 125ms/帧。播放时序的唯一真相源是出参的 + ``durations``。 + - ``loop``:零消费方。闭环行为写死在 slicing.pick_cycle —— 循环类动作一律抽单周期 + 闭环,传 pingpong / none 不改变任何产出。调用方能为一段往返动画付费、拿到一段 + 线性循环,正是本项目最忌讳的"静默成功"。 + + 与 palette 那两条同一条理由:没有实现的取值等于死代码,它让调用方以为该能力存在。 + """ + assert "fps" not in ActionSpec.model_fields + assert "loop" not in ActionSpec.model_fields + + +@pytest.mark.parametrize(("field", "value"), [("fps", 20), ("loop", "pingpong")]) +def test_passing_fps_or_loop_now_fails_loudly(field, value): + """删字段要让旧调用方听得见响 —— extra="forbid" 保证不是被静默丢弃。""" + with pytest.raises(ValidationError): + ActionSpec(action=ActionType.WALK, **{field: value}) + + +def test_loop_mode_enum_is_gone_from_the_public_surface(): + """枚举本身也要删:留着它,下一个人会以为只是暂时没接线而照着填。 + + 真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。 + """ + import windup_common.models as m + + assert not hasattr(m, "LoopMode") + assert "LoopMode" not in m.__all__