From 72857db1d3dd1ac7ed145fd8dd5b07c3204c689b Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Wed, 29 Jul 2026 18:59:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(common):=20=E8=A7=92=E8=89=B2=E5=8D=A1?= =?UTF-8?q?=E4=B8=8E=E5=8A=A8=E4=BD=9C=E8=A7=84=E6=A0=BC=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨层契约,无内部依赖。ai_engine 与 app 均依赖此,避免两层各自定义同名结构。 - CharacterCard 角色身份(一致性主键,资产库基础) - ActionSpec 动作规格(帧数 / 帧率 / 循环模式 / 风格化 / 朝向) - ActionType idle / walk / run / jump / attack / hit - GenRoute video_i2v / per_frame GenRoute 只列有实现的路线。没有实现的枚举值等于死代码:它让调用方以为该能力存在, 而分流到它只能得到运行时错误。未来路线(三渲二渲染出帧)的契约需求记在 #81 / #122, 随实现一起加成员——枚举加成员是纯加法,不构成破坏性变更。 Refs 1024XEngineer/Windup#53 --- .../src/windup_common/models/__init__.py | 13 ++++ .../src/windup_common/models/character.py | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 backend/packages/common/src/windup_common/models/__init__.py create mode 100644 backend/packages/common/src/windup_common/models/character.py 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..b249ee40 --- /dev/null +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -0,0 +1,13 @@ +from windup_common.models.character import ( + ActionSpec, + ActionType, + CharacterCard, + GenRoute, +) + +__all__ = [ + "ActionType", + "GenRoute", + "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..12e0bcb6 --- /dev/null +++ b/backend/packages/common/src/windup_common/models/character.py @@ -0,0 +1,65 @@ +"""共享 DTO —— 跨层契约(common,无内部依赖)。 + +产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举。 +仅定义结构,不含行为。ai_engine / app 均依赖此。 +""" +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class ActionType(str, Enum): + """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。""" + + 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 CharacterCard(BaseModel): + """角色卡 —— 一致性主键 + 资产库基础(产品核心实体)。""" + + name: str + desc: str # 身份描述(喂模型锁一致性) + palette: str = "" + view: str = "pseudo-side" # side / topdown / isometric + master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) + version: str = "v1" + + +class ActionSpec(BaseModel): + """动作规格 —— 帧数 / 帧率 / 循环模式 / 逐帧姿势 / 风格化。""" + + action: ActionType + fps: int = 10 + loop: str = "linear" # none / linear / pingpong + poses: list[str] = Field(default_factory=list) + # 风格化:pixel=像素化(原生像素角色 i2v 后复原像素感);none=保留 i2v 插画质感。 + # 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由 CharacterCard 画风决定。 + stylize: str = "pixel" # pixel / none + pixel_h: int = 100 # 像素化目标高(角色像素行数) + palette_size: int = 32 # 色板色数 + # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective): + # side=横版侧走 / front=俯视·2.5D 朝观者。不一致会让模型靠转身调和图文矛盾。 + facing: str = "side" # side / front + + @property + def n_frames(self) -> int: + return len(self.poses) From 3add0338fbba51712605b740ead214e8a6722c3e Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 14:53:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(common):=20=E5=8F=97=E9=99=90?= =?UTF-8?q?=E5=8F=96=E5=80=BC=E6=94=B9=E6=9E=9A=E4=B8=BE=EF=BC=8Cn=5Fframe?= =?UTF-8?q?s=20=E7=8B=AC=E7=AB=8B=EF=BC=8C=E5=88=A0=E6=8E=89=E9=9B=B6?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E6=96=B9=E7=9A=84=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR 是 #152 的前置:它的 `ai_engine/ports` 与 `strategy/concrete` 都 import `windup_common.models` 的 `CharacterCard` / `ActionSpec`,而那两个符号在主线上不存在 (`models/` 下只有 `.gitkeep`),合入即 import 失败。 ## 为什么受限取值一律用枚举 `facing` 承载一条实测挣得的硬约束——提示词朝向必须与母版朝向一致(给正面母版喂侧走词, 模型会靠转身调和图文矛盾)。它此前是裸 str、合法值只写在行尾注释里:写成 "Side" / "sidee" 不报错、不告警,调用链一路放行,几分钟和一次真金白银的视频调用之后才在画面上 看出角色转了身。枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时 ValidationError",成本从一次付费生成降到零。`loop` / `stylize` / `view` 同理。 `view` 的取值与前端契约(frontend/API_CONTRACT.md 的映射表)逐字一致,免得将来做 int ↔ str 映射时再造一套别名(topdown / top_down / top-down 三写)。 ## extra="forbid" 理由与用枚举同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)` (少个 s)在 pydantic 默认的 extra="ignore" 下不报错、不生效,调用方以为要了 16 帧、 实际拿到默认 8 帧。已删字段(如 palette)同理会被静默吞掉。 ## n_frames 独立成字段 原先由 `len(poses)` 推导,但视频路线根本不读 poses——推导意味着"想要 16 帧就得先编 16 条用不上的姿势描述",而那 16 条描述读者会以为真的进了提示词。 只传 poses 的旧调用方零改动(回退到 len(poses));两个都给且不等时抛错而不是猜一个—— common 层看不到 ROUTE_MATRIX,判不出走哪条路线,猜的代价是静默出错帧数。 ## 删掉 palette 它会变成"看起来生效、实则被忽略"的第二真相源:真正锁色的色板由 postprocess.master_pixel_spec 从母版像素里量出来,而这个字段零消费方、无格式约定。 调用方填了 "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,不报错也不生效。 将来若要支持用户指定色板,连同消费它的代码一起加回,并用结构化类型而非自由 str。 ## 数值下界抄的是实现里已有的真实取值域 把"实现悄悄纠正入参"提前成入参报错:`pixel_h` → to_pixel_art 对 <1 直接 raise; `palette_size` → `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2,于是 "我要 1 色"拿到 2 色且无任何提示;`fps` → 0 对播放侧是除零/静止,没有合法语义。 Refs 1024XEngineer/Windup#171 Refs 1024XEngineer/Windup#53 --- .../src/windup_common/models/__init__.py | 10 + .../src/windup_common/models/character.py | 182 ++++++++++++++++-- 2 files changed, 171 insertions(+), 21 deletions(-) diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py index b249ee40..3388da5f 100644 --- a/backend/packages/common/src/windup_common/models/__init__.py +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -1,13 +1,23 @@ from windup_common.models.character import ( + DEFAULT_N_FRAMES, ActionSpec, ActionType, CharacterCard, + CharacterView, + Facing, GenRoute, + LoopMode, + Stylize, ) __all__ = [ "ActionType", "GenRoute", + "Facing", + "CharacterView", + "LoopMode", + "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 index 12e0bcb6..fc5bc658 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -2,12 +2,32 @@ 产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举。 仅定义结构,不含行为。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") -from pydantic import BaseModel, Field + +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): @@ -33,33 +53,153 @@ class GenRoute(str, Enum): 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 LoopMode(str, Enum): + """出帧的循环模式(**生成侧**,不是播放器那个 loop 开关)。 + + 与持久化的 ``character_data.actions[].loop``(bool,是否循环播放)不是同一件事: + 这里决定的是帧序列怎么排——linear 按时间顺序、pingpong 往返省一半帧、none 不成环。 + """ + + NONE = "none" + LINEAR = "linear" + PINGPONG = "pingpong" + + +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 # 身份描述(喂模型锁一致性) - palette: str = "" - view: str = "pseudo-side" # side / topdown / isometric - master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) + 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): - """动作规格 —— 帧数 / 帧率 / 循环模式 / 逐帧姿势 / 风格化。""" + """动作规格 —— 帧数 / 帧率 / 循环模式 / 逐帧姿势 / 风格化 / 朝向。""" + + model_config = _STRICT action: ActionType - fps: int = 10 - loop: str = "linear" # none / linear / pingpong + # fps 只被抄进 GeneratedAction.fps 交给播放侧;逐帧时长另有来源(postprocess. + # frame_durations 按动作查表,**不看 fps**)。gt=0 是因为 0 对播放侧是除零/静止, + # 没有任何合法语义 —— 取值域写进约束,别让它靠"没人会传 0"活着。 + fps: int = Field(default=10, gt=0) + + # 循环模式。**注意:ai_engine 当前零消费方**(2026-08-08 复核:`grep 'action\.loop'` + # 零命中)。实际闭环行为写死在 slicing.pick_cycle 里——循环类动作一律抽单周期闭环 + # (≈LINEAR),与本字段无关;传 PINGPONG / NONE 今天不会改变任何产出。保留字段是因为 + # 它语义明确、无竞争真相源(与已删的 palette 不同),但**调用方别指望它生效**; + # 真要支持时连同 pick_cycle 的分支一起加,并在此删掉这段注释。 + loop: LoopMode = LoopMode.LINEAR + + # 出帧数。**显式字段,不再由 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) - # 风格化:pixel=像素化(原生像素角色 i2v 后复原像素感);none=保留 i2v 插画质感。 - # 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由 CharacterCard 画风决定。 - stylize: str = "pixel" # pixel / none - pixel_h: int = 100 # 像素化目标高(角色像素行数) - palette_size: int = 32 # 色板色数 - # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective): - # side=横版侧走 / front=俯视·2.5D 朝观者。不一致会让模型靠转身调和图文矛盾。 - facing: str = "side" # side / front - - @property - def n_frames(self) -> int: - return len(self.poses) + + 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 From 9595edf704e4a65b4d75cad7d5912bc1bdbc6790 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 21:51:26 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(common):=20=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E6=8E=A5=E4=BA=86=E4=B8=8D=E5=B1=A5=E7=BA=A6?= =?UTF-8?q?=E7=9A=84=E5=85=A5=E5=8F=82=EF=BC=8C=E5=B9=B6=E7=BB=99=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E8=A1=A5=E8=87=AA=E5=B8=A6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器审在本 PR 报的问题指向同一件事:契约里存在"能填、但填了不生效"的字段。本 PR 自己的 GenRoute docstring 已经写了这条原则("只列有实现的路线;没有实现的枚举值等于死代码"), 只是没对 ActionSpec 执行。 删除: - ActionSpec.fps —— 零写入方(编排层构造 ActionSpec 时从不传),而 postprocess. frame_durations 按动作查表、根本不看它。留着的后果是同一段素材有两个互相矛盾的播放 速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧。播放时序的唯一真相源定为出参的 durations(逐帧 ms),比单一帧率严格更能表达(关键帧定格)。 - ActionSpec.loop 与 LoopMode 枚举 —— 零消费方。闭环行为写死在 slicing.pick_cycle: 循环类动作一律抽单周期闭环,传 pingpong / none 不改变任何产出。调用方能为一段往返 动画付费、拿到一段线性循环,正是本项目最忌讳的静默成功。此前的处理是写注释说明"别 指望它生效",并写了一条把"三种 loop 产出相同"钉成事实的测试 —— 那是把缺陷固化,不是 修。真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。 补文档: - ActionType docstring 显式声明本枚举是"引擎能生成的动作",与入口侧 orchestrator.model.ActionType(另有 custom,少 run/jump/hit)刻意分离,跨越靠编排层 的显式适配函数 _to_engine_action。不把 custom 加进来的理由与上面同一条:ROUTE_MATRIX 没有它的分流,加成员等于接收一个无法履约的请求。API 入口枚举不变,既有调用方兼容性 不受影响。 补测试(本 PR 此前零测试,而它是其余分片的硬前置): - 新增 tests/test_character_contract.py,35 条,只测 DTO 自身、不 import 上层包 —— 契约包要能独立验证。提示词构造器那几条依赖 ai_engine,随实现分片走。 - 覆盖:受限取值拒绝拼写错误、字段名打错不被静默丢弃(extra="forbid")、n_frames 与 poses 自相矛盾时抛错而非二选一、取值域下界、以及 palette / fps / loop 三个已删字段 传入时必须听得见响。 - 做过变异测试:把 fps 加回去 2 条红,把 loop + LoopMode 加回去 3 条红。 --- .../src/windup_common/models/__init__.py | 2 - .../src/windup_common/models/character.py | 51 ++-- backend/tests/test_character_contract.py | 253 ++++++++++++++++++ 3 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_character_contract.py diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py index 3388da5f..52434b34 100644 --- a/backend/packages/common/src/windup_common/models/__init__.py +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -6,7 +6,6 @@ CharacterView, Facing, GenRoute, - LoopMode, Stylize, ) @@ -15,7 +14,6 @@ "GenRoute", "Facing", "CharacterView", - "LoopMode", "Stylize", "DEFAULT_N_FRAMES", "CharacterCard", diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index fc5bc658..3d4cefdb 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -31,7 +31,17 @@ def _without(data: dict[str, Any], key: str) -> dict[str, Any]: class ActionType(str, Enum): - """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。""" + """动作类型 —— 决定走哪条生成 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" @@ -80,18 +90,6 @@ class CharacterView(str, Enum): ISOMETRIC = "isometric" # perspective=3 2.5D -class LoopMode(str, Enum): - """出帧的循环模式(**生成侧**,不是播放器那个 loop 开关)。 - - 与持久化的 ``character_data.actions[].loop``(bool,是否循环播放)不是同一件事: - 这里决定的是帧序列怎么排——linear 按时间顺序、pingpong 往返省一半帧、none 不成环。 - """ - - NONE = "none" - LINEAR = "linear" - PINGPONG = "pingpong" - - class Stylize(str, Enum): """风格化模式。 @@ -132,22 +130,25 @@ class CharacterCard(BaseModel): 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 - # fps 只被抄进 GeneratedAction.fps 交给播放侧;逐帧时长另有来源(postprocess. - # frame_durations 按动作查表,**不看 fps**)。gt=0 是因为 0 对播放侧是除零/静止, - # 没有任何合法语义 —— 取值域写进约束,别让它靠"没人会传 0"活着。 - fps: int = Field(default=10, gt=0) - - # 循环模式。**注意:ai_engine 当前零消费方**(2026-08-08 复核:`grep 'action\.loop'` - # 零命中)。实际闭环行为写死在 slicing.pick_cycle 里——循环类动作一律抽单周期闭环 - # (≈LINEAR),与本字段无关;传 PINGPONG / NONE 今天不会改变任何产出。保留字段是因为 - # 它语义明确、无竞争真相源(与已删的 palette 不同),但**调用方别指望它生效**; - # 真要支持时连同 pick_cycle 的分支一起加,并在此删掉这段注释。 - loop: LoopMode = LoopMode.LINEAR # 出帧数。**显式字段,不再由 len(poses) 推导**:视频路线根本不读 poses(见 # strategy.concrete.VideoFrameStrategy),推导意味着"想要 16 帧就得先编 16 条用不上的 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__