From 66a57e2c6f52268db80499178c9bb4050ccd608c Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Thu, 13 Aug 2026 15:53:05 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(render3d):=20=E4=B8=89=E6=B8=B2?= =?UTF-8?q?=E4=BA=8C=E6=8E=A5=E8=BF=9B=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引擎契约与 server 编排的后半段。路线选择由 server 读 DB 决定:造型上有 model_3d_url 就调 CharacterGeneratorPort.generate_rendered,没有则照旧走 i2v。 三渲二不进 ROUTE_MATRIX —— 那张表的隐含前提是"路线由动作物理性质唯一决定", 而这条还取决于该造型有没有 3D 资产。 引擎侧新增 generate_rendered 而不另立 port,并补一项分区动量读数:整幅的 motion_scale 与死帧判据看不见"腿在迈、手臂僵成柱子",而那正是自动绑骨漏认 肢体的典型产物。 出帧台 provider 走函数内延迟 import,没装它时 i2v / 逐帧两条路线仍可用。 Refs 1024XEngineer/Windup#192 #122 #121 --- .../impl/character_generator.py | 71 ++- .../src/windup_ai_engine/ports/__init__.py | 35 +- .../src/windup_ai_engine/slicing/__init__.py | 8 +- .../src/windup_ai_engine/slicing/_frames.py | 23 +- .../src/windup_ai_engine/slicing/quality.py | 46 +- .../src/windup_ai_engine/strategy/__init__.py | 5 +- .../src/windup_ai_engine/strategy/base.py | 24 +- .../src/windup_ai_engine/strategy/concrete.py | 131 +++- .../src/windup_app/server/character/model.py | 12 + .../server/orchestrator/executor.py | 56 +- .../windup_app/server/orchestrator/model.py | 10 + .../server/orchestrator/render3d_assets.py | 252 ++++++++ .../app/src/windup_app/web/api/generation.py | 37 +- .../src/windup_common/models/character.py | 8 + backend/tests/test_ai_engine_skeleton.py | 5 +- backend/tests/test_orchestrator_hardening.py | 6 + .../tests/test_render3d_route_and_assets.py | 594 ++++++++++++++++++ 17 files changed, 1288 insertions(+), 35 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py create mode 100644 backend/tests/test_render3d_route_and_assets.py 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 fd2e9ef8..d8ed322b 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 @@ -27,7 +27,12 @@ ProgressPort, ) from windup_ai_engine.postprocess import align_bottom_center, frame_durations -from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale +from windup_ai_engine.slicing import ( + dead_frame_indices, + limb_motion, + loop_seam, + motion_scale, +) from windup_ai_engine.strategy.base import ( ROUTE_MATRIX, DerivationStrategy, @@ -102,29 +107,73 @@ def generate( # ② 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, # 不要让"看着成功、内容是空"的结果流到 server 去落库。 + # + # 三渲二**不经过这里**:它由 server 读 DB 判断该造型有没有 3D 资产后直接调 + # generate_rendered(#122)。ROUTE_MATRIX 因此仍然只映射"由动作物理性质唯一 + # 决定"的那两条,它的隐含前提不被破坏(见 strategy.base 模块注释)。 route = ROUTE_MATRIX[action.action] # .value 而不是枚举本身:Python 3.11+ 的 str-mixin 枚举 __format__ 会给出 # "ActionType.WALK",这串字最终是用户看到的进度文案(3.12.13 实测)。 progress.step("route", _TICK_ROUTE, _TOTAL, f"{action.action.value} → {route.value}") + strategy = self._pick(route, action) + + # ③ 生成帧(交给 strategy —— 串联) + frames = strategy.derive( + card, action, master, _BandProgress(progress, _DERIVE_FROM, _DERIVE_TO) + ) + return self._finish(frames, action, route, progress, canvas) + + def generate_rendered( + self, + card: CharacterCard, + action: ActionSpec, + rigged_model: bytes, + progress: ProgressPort, + canvas: tuple[int, int] | None = None, + ) -> GeneratedAction: + """三渲二入口。见 ``ports.CharacterGeneratorPort.generate_rendered``。 + + **没有母版预检那一道**,不是漏了:``check_master`` 判的是尺寸 / 比例 / 空图, + 而这条路线的输入是 3D 模型,那些判据一条都不适用。模型自身的预检在 server + 建资产那一步做 —— 那才是花钱的地方,这里已经不花钱了。 + """ + route = GenRoute.RENDER_3D + progress.step("route", _TICK_ROUTE, _TOTAL, f"{action.action.value} → {route.value}") + strategy = self._pick(route, action) + frames = strategy.derive( + card, action, rigged_model, _BandProgress(progress, _DERIVE_FROM, _DERIVE_TO) + ) + return self._finish(frames, action, route, progress, canvas) + + # ── 两个入口共用的部分 ──────────────────────────────────────────────── + def _pick(self, route: GenRoute, action: ActionSpec) -> DerivationStrategy: strategy = self._by_route.get(route) if strategy is None: raise NotImplementedError( f"动作 {action.action.value} 分流到 {route.value},但未注入该路线的 strategy。" f"已装配:{sorted(r.value for r in self._by_route)}。" ) + return strategy - # ③ 生成帧(交给 strategy —— 串联) - frames = strategy.derive( - card, action, master, _BandProgress(progress, _DERIVE_FROM, _DERIVE_TO) - ) + def _finish( + self, + frames: list[bytes], + action: ActionSpec, + route: GenRoute, + progress: ProgressPort, + canvas: tuple[int, int] | None, + ) -> GeneratedAction: + """出帧之后的公共尾段:帧数对账 → 脚线对齐 → 量成色 → 出参。 - # ③.5 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 + 两个入口共用同一份,不是为了少写几行:这几道闸是**对所有路线**的约束, + 复制一份就会有一天只在一条路线上被改。 + """ + # 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 # 承诺,而抽帧那两个函数都会**静默少给**:slicing.pick_cycle / pick_oneshot 在 # `len(dense) <= n`(或动作区间比 n 短)时 return frames/span,长度不足且不报错 # (2026-08-08 读码复核)。少给的后果不是崩溃而是"短一截的动作":时长表由 # frame_durations(…, len(frames)) 现算,长度自洽,server 看不出异常,用户拿到 # 一段步子没走完的循环。故在此对账 —— 钱已经花了,但至少不让错产物流下去。 - # 放在 generator 而不是某个 strategy 里:这样将来任何新路线都受同一条约束。 if len(frames) != action.n_frames: raise ValueError( f"{route.value} 要 {action.n_frames} 帧,实际产出 {len(frames)} 帧。" @@ -132,14 +181,13 @@ def generate( "请调小 n_frames 或加长视频。" ) - # ④ 最后一公里:脚线对齐成原地序列帧(直接对齐到调用方要的画布尺寸) + # 最后一公里:脚线对齐成原地序列帧(直接对齐到调用方要的画布尺寸) aligned = self._lastmile(frames, progress, canvas) - # ⑤ 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 / + # 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 / # 对齐都会改像素,在中间任何一步量出来的数都描述不了交付物。 quality = self._assess(aligned, action) - # ⑥ 出参:帧 + 逐帧时长 + 成色(上传 / 落库在 server 侧) progress.step( "package", _TICK_PACKAGE, _TOTAL, f"{len(aligned)} 帧 + 逐帧时长(动量 {quality.motion_scale:.2f}," @@ -165,6 +213,9 @@ def _assess(self, frames: list[Image.Image], action: ActionSpec) -> ActionQualit motion_scale=motion_scale(frames), dead_frames=dead_frame_indices(frames), loop_seam=loop_seam(frames) if is_cyclic(action) else None, + # 分区动量:整幅指标的盲区补充。自动绑骨漏认一条肢体时那块网格每帧同姿势, + # 而 motion_scale 与死帧全部正常。 + limbs=limb_motion(frames), ) def _lastmile( 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 c3eb1684..dd6e5726 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 @@ -63,7 +63,7 @@ class ActionQuality: 一段**每帧都一样**的 walk 和一段步态干净的 walk,帧数、时长、fps 完全相同, 调用方分辨不出 —— 本仓吃过四次的正是这类"看起来成功的错结果"。 - 三个字段各自不可由其他两个推导(下面逐条说明必要性)。刻意**没有**的字段: + 四个字段各自不可由其余推导(下面逐条说明必要性)。刻意**没有**的字段: - 糊帧率(``slicing.quality.blur_ratio``):2026-08-05 实测 6 段真 i2v **没有一帧糊帧**,加进来是个恒等于 1 的常数,上层拿它做不了任何决定。 真出现糊帧再加,那时才有阈值可依。 @@ -72,6 +72,14 @@ class ActionQuality: 今天没有任何调用方会据此改变行为,故不塞进出参。 """ + limbs: dict[str, float] = field(default_factory=dict, kw_only=True) + """分区动量(``slicing.quality.limb_motion``):``{区名: 占比}`` + 最静区名 ``still``。 + + 补 ``motion_scale`` / ``dead_frames`` 的共同盲区 —— 那两个看的是**整幅**,而 + "腿在迈、手臂僵成柱子"整幅指标完全正常。某个该动的区占比接近 0 = 那块网格没被 + 骨骼驱动(多半是自动绑骨漏认了肢体),别交付。 + """ + motion_scale: float """交付帧的相邻帧平均差异(48×48 灰度绝对尺度)。**0.0 = N 张同一张图。** @@ -136,8 +144,7 @@ class CharacterGeneratorPort(Protocol): 在 ai_engine 下零命中(2026-08-08 复核)。这不是遗漏:i2v 的角色身份完全由 ``master`` 这张母版图像承载,身份描述再写一遍反而会和母版打架。本参数是给 未实现路线预留的入参:逐帧图生图(#53)要靠 ``name`` / ``desc`` 在每帧提示词里 - 锁一致性,渲染出帧(#81 #122)要靠 ``master_ref`` / ``version`` 定位 3D 资产。 - **调用方不要指望改 card 能影响视频路线的产出。** + 锁一致性。**调用方不要指望改 card 能影响视频路线的产出。** action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。视频路线的实际入参在这里: ``action``、``n_frames``、``facing``、``stylize`` 等。 master: 定妆母版图 bytes(server 从 reference_image_url 取)。**视频路线的 @@ -169,3 +176,25 @@ def generate( progress: ProgressPort, canvas: tuple[int, int] | None = None, ) -> GeneratedAction: ... + + def generate_rendered( + self, + card: CharacterCard, + action: ActionSpec, + rigged_model: bytes, + progress: ProgressPort, + canvas: tuple[int, int] | None = None, + ) -> GeneratedAction: + """三渲二:拿**已绑骨的 3D 模型**套预设动作、渲成 2D 序列帧。 + + 与 :meth:`generate` 并列而不另立 port —— server 调 ai_engine 只该有一个入口。 + **调哪个由 server 决定,引擎不选**:判据"该造型有没有 3D 资产"只有 DB 知道 + (``character_data.outfits[].model_3d_url``),故本方法不提供"能不能用"的预查询。 + ``rigged_model`` 传 bytes 不传 URL —— ai_engine 只吃 bytes、不碰存储;取模型与 + **建**模型那笔按次计费都在 server 侧,不在这条出帧路径上。 + + Raises: + NotImplementedError: 没注入 ``GenRoute.RENDER_3D`` 的 strategy。 + ValueError: 模型渲不出请求朝向 / 产出帧数对不上契约。 + """ + ... diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py index d00d4ed6..d1ce7d2e 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py @@ -4,7 +4,8 @@ loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。 :mod:`.quality` 原本纯做诊断,现在还兼一份出参职责:交付帧的成色读数 -(``motion_scale`` / ``dead_frame_indices`` / ``loop_seam``)汇成 ``ports.ActionQuality``。 +(``motion_scale`` / ``dead_frame_indices`` / ``loop_seam`` / ``limb_motion``)汇成 +``ports.ActionQuality``。 注意它**仍然不参与选帧** —— 那条消融结论没变,见 :func:`.loop.pick_cycle`。 """ @@ -17,15 +18,16 @@ pick_oneshot, split_jump_phases, ) -from .quality import dead_frame_indices, loop_seam, motion_scale +from .quality import dead_frame_indices, limb_motion, loop_seam, motion_scale __all__ = [ "extract_frames_bytes", "extract_all_frames_bytes", "find_period", "pick_cycle", - # 交付成色的三个读数(汇成 ports.ActionQuality;其余 quality.* 仍是内部诊断) + # 交付成色的四个读数(汇成 ports.ActionQuality;其余 quality.* 仍是内部诊断) "dead_frame_indices", + "limb_motion", "loop_seam", "motion_scale", "find_motion_span", diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py index 81066126..c8e36f6e 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py @@ -9,7 +9,7 @@ import numpy as np from PIL import Image -__all__ = ["SMALL", "gray"] +__all__ = ["SMALL", "gray", "alpha_stack"] # 帧比对统一降采样到 48×48 灰度:够分辨姿态差异,又让全帧对距离矩阵的开销可接受。 SMALL = 48 @@ -18,3 +18,24 @@ def gray(frames: list[Image.Image]) -> list[np.ndarray]: """帧序列 → 定尺灰度矩阵列表(float32)。""" return [np.asarray(f.convert("L").resize((SMALL, SMALL)), dtype=np.float32) for f in frames] + + +# 分区动量用的尺度。比 SMALL 大,因为它要在**主体包围盒内**再切 3×2 个区 —— +# 48×48 切完每区只剩 16×24,一条手臂占不到几个像素,量出来的差异全是量化噪声。 +MASK = 128 + + +def alpha_stack(frames: list[Image.Image]) -> np.ndarray: + """帧序列 → ``(n, MASK, MASK)`` 的主体掩码栈(bool)。 + + 用 alpha 而不是灰度:这些帧是抠过图的 RGBA,alpha 就是主体轮廓,而灰度会把 + 深色衣服和透明背景混为一谈。没有 alpha 时退化成"非纯黑即主体"。 + """ + out = [] + for fr in frames: + im = fr.resize((MASK, MASK)) + if im.mode == "RGBA": + out.append(np.asarray(im.getchannel("A")) > 128) + else: + out.append(np.asarray(im.convert("L")) > 8) + return np.stack(out) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py index 734274ec..92d0d311 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py @@ -13,7 +13,7 @@ from ._frames import gray as _gray __all__ = ["active_span", "blur_ratio", "dead_frame_indices", "dead_frame_mask", - "frame_deltas", "loop_seam", "motion_scale"] + "frame_deltas", "limb_motion", "loop_seam", "motion_scale"] def frame_deltas(frames) -> np.ndarray: @@ -146,3 +146,47 @@ def _ag(g): m = cand & (mv <= np.sort(mv[cand])[:k].max()) out[i] = float(np.median(sharp[i][m] / np.maximum(ref[m], 1e-6))) return out + + +def limb_motion(frames, *, grid: tuple[int, int] = (3, 2)) -> dict[str, float]: + """按身体分区量动量,返回**各区占总动量的比例** + 最静区名 ``still``。 + + 存在的理由是整幅平均逮不到"一部分肢体在动、另一部分冻着"::func:`motion_scale` + 与 :func:`dead_frame_mask` 的判据都是相对的,整体在动时它们全绿,而"腿在迈、手臂 + 僵成柱子"正是**自动绑骨漏认肢体**的典型表现 —— 那块网格没有骨骼驱动,每帧同姿势。 + + 只报占比、不给合格线:几何分区区分不了"该动没动"和"本来就不该动",那需要动作语义。 + 试过的两个汇总判据都不成立 —— ``max/min`` 的分母常落在本就不该动的静区, + ``max/median`` 在半数区冻结时中位数落到活跃那侧,恰在最该报警时比值 ≈ 1.0。 + 故**判决交给调用方**,与 :class:`ports.ActionQuality` 其余读数同一取向。 + + 读法:6 区均匀分布时各占 ≈0.17;某区接近 0 且按动作语义**该动**,那块多半没被骨骼 + 驱动。分区按主体包围盒切(不是整幅画布),否则角色在画面里位置一变区就对不上。 + """ + from ._frames import alpha_stack as _masks + + m = _masks(frames) + if m.shape[0] < 2: + return {"still": ""} + ys, xs = np.where(m.any(0)) + if not len(ys): + return {"still": ""} + y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1 + rows, cols = grid + hs = np.linspace(y0, y1, rows + 1).astype(int) + ws = np.linspace(x0, x1, cols + 1).astype(int) + names_r = ("上", "中", "下")[:rows] + names_c = ("左", "右")[:cols] if cols == 2 else tuple(str(i) for i in range(cols)) + + raw: dict[str, float] = {} + for r in range(rows): + for c in range(cols): + blk = m[:, hs[r]:hs[r + 1], ws[c]:ws[c + 1]] + raw[f"{names_r[r]}{names_c[c]}"] = float(blk.std(0).sum()) + total = sum(raw.values()) + out: dict[str, float] = {k: round(v / total, 3) if total > 0 else 0.0 + for k, v in raw.items()} + # 在**只有分区**的字典上挑最静区,再把它写回去。反过来的话 `still` 会挑中自己写进去 + # 的汇总键 —— 一个只在数据上体现、不会报错的错。 + out["still"] = min(raw, key=lambda k: raw[k]) + return out diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py index 8435572e..afb5865d 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py @@ -1,7 +1,7 @@ -"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。""" +"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 各条 DerivationStrategy。""" from .base import CYCLIC_ACTIONS, ROUTE_MATRIX, DerivationStrategy -from .concrete import PerFrameStrategy, VideoFrameStrategy +from .concrete import PerFrameStrategy, RenderFrameStrategy, VideoFrameStrategy __all__ = [ "ROUTE_MATRIX", @@ -9,4 +9,5 @@ "DerivationStrategy", "VideoFrameStrategy", "PerFrameStrategy", + "RenderFrameStrategy", ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py index ffeb5d1a..1865ad26 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -62,13 +62,20 @@ def is_cyclic(action: ActionSpec) -> bool: # 本矩阵的形状本身有个已知边界,记录在此以免后来者按错误前提扩展: # 它是「动作类型 → 路线」的一对一映射,隐含前提是"路线由动作的物理性质唯一决定"。 # 该前提对逐帧 / 视频两条路线成立(有无连续步态是动作固有属性),但对渲染出帧路线不成立 -# —— 同一个 walk 既可走 i2v 也可走渲染,选哪条取决于"该角色有没有 3D 模型",那是 server -# 才知道的事。接入第三条路线前须先定「路线选择由谁决定」,并可能要把本矩阵改成 -# 「动作类型 → 可选路线集合」+ 一个选择器。Refs 1024XEngineer/Windup#81 #122。 +# —— 同一个 walk 既可走 i2v 也可走渲染,选哪条取决于"该造型有没有 3D 模型",那是 server +# 才知道的事。 +# +# 所以渲染出帧路线**不进本矩阵**:server 读 DB 判断该造型有没有 3D 资产,再直接调 +# ``CharacterGeneratorPort.generate_rendered``。本矩阵只留"由动作物理性质唯一决定"的 +# 那两条,隐含前提得以保住。反过来把它改成「动作 → 可选路线集合」+ 选择器,等于把一个 +# 只有 DB 答得出的问题塞进引擎。Refs 1024XEngineer/Windup#81 #122。 class DerivationStrategy(ABC): - """一条生成路线的骨架:母版 → 对齐前的角色帧序列。""" + """一条生成路线的骨架:一份源 bytes → 对齐前的角色帧序列。 + + 没有"我能不能服务这个角色"的自报钩子:那个判据在 DB 里,只有 server 看得到。 + """ route: GenRoute @@ -77,8 +84,13 @@ def derive( self, card: CharacterCard, action: ActionSpec, - master: bytes, + source: bytes, progress: ProgressPort, ) -> list[bytes]: - """从母版 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。""" + """从源 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。 + + ``source`` 随路线不同:i2v / 逐帧吃定妆母版图,三渲二吃已绑骨的 3D 模型 —— + 两者由各自的入口喂进来,不存在传错。各实现按自己吃的东西给形参命名, + 统一叫 ``master`` 会让渲染路线的签名说谎。 + """ raise NotImplementedError diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 4274ddbd..194458f5 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -3,16 +3,26 @@ - VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。 - PerFrameStrategy:**未实现**,调用即抛 NotImplementedError(见 #53)。不返回空帧—— 空帧会伪装成一次成功的生成流到 server 落库,用户看到的是一组裂图。 +- RenderFrameStrategy:三渲二。吃**已绑骨的 3D 模型**(不是母版图),纯本地渲帧、 + 零 API 成本;建模型那两段按次计费的活在 server 侧(#121 #122)。 VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 → matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。 """ from __future__ import annotations +from typing import TYPE_CHECKING import numpy as np -from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute, Stylize +from windup_common.models import ( + ActionSpec, + ActionType, + CharacterCard, + Facing, + GenRoute, + Stylize, +) from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider from windup_ai_engine._imgio import from_png as _img @@ -30,6 +40,9 @@ ) from windup_ai_engine.strategy.base import DerivationStrategy, is_cyclic +if TYPE_CHECKING: + from windup_framework.providers.render3d import SpriteRenderProvider, SpriteSheet + class VideoFrameStrategy(DerivationStrategy): """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。 @@ -153,6 +166,122 @@ def derive( ) +# Facing → 出帧台的朝向名。键名与前端导出模型的 ExportAction.sequences[].direction +# 同域,不需要转换层。 +# +# **值是真渲一遍量出来的,不能按方位名推。** 直觉上 "south = 朝观者",实际 n(yaw=90°) +# 才是正面(奶白胸腹与口鼻可见,浅色像素占比 21.0%),s 是背面(8.5%)。两者主体像素数 +# 几乎相同,靠轮廓分不出正反,而单元测试也逮不到 —— 朝向错了但帧数、时长、成色全部正常。 +# 改这张表之前先渲一遍再量。 +_FACING_TO_DIRECTION: dict[Facing, str] = { + Facing.SIDE: "e", # yaw=0°,角色朝画面右(与出帧台 faces="right" 同口径) + Facing.FRONT: "n", # yaw=90°,身体正对观者 +} + + +class RenderFrameStrategy(DerivationStrategy): + """三渲二:已绑骨的 3D 模型 → 套预设动作 → 渲 2D 序列帧。 + + **吃的是绑骨模型,不是母版图。** 图生 3D 与自动绑骨那两段按次计费、每造型一次性, + 由 server 侧的 ``Render3DAssetBuilder`` 负责(#121);走到这里钱已经花完,本段纯本地、 + 零 API 成本。相对 i2v 的独占优势是**多朝向零成本且跨朝向天生一致**。 + """ + + route = GenRoute.RENDER_3D + + def __init__( + self, + renderer: SpriteRenderProvider, + *, + directions: int = 4, + material: str = "cel", + size: tuple[int, int] | None = None, + ) -> None: + # 出帧台的 provider 包只在这条路线上用得着(它连着 node + 浏览器)。在模块顶层 + # import 会让走 i2v / 逐帧的部署也必须装齐它,而这两条路线一行都用不到。 + from windup_framework.providers.render3d import RENDER_SIZE + + self._renderer = renderer + self._directions = directions + self._material = material + self._size = size or RENDER_SIZE + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + rigged_model: bytes, + progress: ProgressPort, + ) -> list[bytes]: + if not rigged_model: + # 空模型必须在这里炸:让它流下去的话出帧台只报一句"Bad glTF", + # 排查方向会整个跑偏到出帧管线上。 + raise ValueError( + f"三渲二拿到空的绑骨模型(角色 {card.name!r}、动作 {action.action.value})。" + "server 侧应在调用前确认该造型的 3D 资产可读。" + ) + + want = _FACING_TO_DIRECTION.get(action.facing, "e") + progress.step( + "derive", 0, 3, + f"渲 {self._directions} 朝向 × {action.n_frames} 帧" + f"({self._size[0]}×{self._size[1]},材质 {self._material})", + ) + sheet: SpriteSheet = self._renderer.render( + rigged_model, + clip=action.action.value, + directions=self._directions, + frames=action.n_frames, + size=self._size, + material=self._material, + ) + + available = tuple(s.direction for s in sheet.sequences) + chosen = next((s for s in sheet.sequences if s.direction == want), None) + if chosen is None: + # 不静默换一个朝向交出去:朝向错了的序列帧就是角色朝反方向走, + # 而帧数、时长、成色全都正常,没有任何一道会红。 + raise ValueError( + f"出帧台没有产出朝向 {want}(动作 {action.action.value}、facing " + f"{action.facing.value});实际产出 {available}。" + ) + frames = list(chosen.frames) + if not frames: + raise ValueError( + f"三渲二未产出任何帧(动作 {action.action.value}、朝向 {chosen.direction})。" + ) + + extra = [d for d in available if d != chosen.direction] + if extra: + # 如实报:这些朝向已经渲出来了、零额外成本,但出参装不下,只能丢。 + # 不写成 warning 日志而是进度文案,因为这串字最终会经 server 到用户眼前, + # 而"多朝向"正是这条路线的卖点 —— 用户该知道它已经算好了。 + progress.step( + "derive", 1, 3, + f"已渲 {len(available)} 个朝向,本次出参只带 {chosen.direction};" + f"其余 {','.join(extra)} 零成本可用但当前契约装不下(#122)", + ) + else: + progress.step("derive", 1, 3, f"朝向 {chosen.direction} 共 {len(frames)} 帧") + + # 3D 帧本来就是透明底,**不套抠图**:去白边那一步会把浅灰甲当漏白吃掉。 + # 像素化仍按 ActionSpec 走。 + if action.stylize is Stylize.NONE: + progress.step("derive", 2, 3, "保留渲染画风(不像素化)") + return frames + + # 像素化的目标高与色板本来该从母版量(master_pixel_spec),但本路线**没有母版** —— + # 它吃的是 3D 模型。所以只能按 ActionSpec 声明的 pixel_h + 通用量化走。 + # 这是一处已知差异,不是遗漏:锁母版色板要靠母版像素,而这条路线上根本没有那张图。 + progress.step("derive", 2, 3, f"像素化(h={action.pixel_h}·通用量化)") + pix = pixelate_frames( + [_img(f) for f in frames], + target_h=action.pixel_h, + palette_size=action.palette_size, + ) + return [_png(p) for p in pix] + + # 注:曾有 ProcIdleStrategy(GenRoute.PROC_IDLE)—— 待机走"母版抠图 + 程序化局部躯干呼吸" # 的零 API 路线(Idle-B,#53 原设计)。**2026-08-07 定案放弃**:程序化呼吸做不出可用效果, # idle 统一走 i2v、认这份钱。GenRoute.PROC_IDLE 一并移除,不留没有实现的枚举值。 diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py index 72f7914f..34efeee3 100644 --- a/backend/packages/app/src/windup_app/server/character/model.py +++ b/backend/packages/app/src/windup_app/server/character/model.py @@ -14,6 +14,7 @@ ├── id str: 造型稳定 ID ├── name str: 造型名称 ├── preview_url str | None: 造型预览图 + ├── model_3d_url str | None: 该造型的绑骨 3D 模型(三渲二路线的开关) └── actions[] list[CharacterAction]: 动作列表 ├── id str: 动作稳定 ID ├── type "idle" | "walk" | "attack" | "custom" @@ -136,6 +137,17 @@ class CharacterOutfit(BaseModel): name: str = Field(..., description="造型名称") description: str | None = Field(default=None, description="造型描述") preview_url: str | None = Field(default=None, description="造型预览图 URL") + # 该造型的**绑骨 3D 模型**存储 URL;``None`` = 还没建。三渲二路线的开关就是它: + # server 读到有值就调 CharacterGeneratorPort.generate_rendered,读到 None 就走 i2v。 + # + # 挂在造型一级而非角色一级(#121):外观挂在造型上(每个造型自带 preview_url), + # 角色级只有一张参考图,同一角色的不同造型共用不了一个 3D 模型。 + # + # 建这份资产是**每造型一次性**的按次计费(图生 3D + 绑骨),不在动作生成的请求 + # 路径上 —— 见 orchestrator.render3d_assets.Render3DAssetBuilder。 + model_3d_url: str | None = Field( + default=None, description="该造型的绑骨 3D 模型 URL;None = 未建,三渲二不可用" + ) actions: list[CharacterAction] = Field(default_factory=list, description="该造型下的动作列表") 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 7459e6f7..300e69c4 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -185,6 +185,7 @@ def __init__( generator: CharacterGeneratorPort | None = None, upload: Callable[[bytes], str] | None = None, fetch_master: Callable[[CharacterActionInput], bytes] | None = None, + fetch_model3d: Callable[[str], bytes] | None = None, fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None, session_factory: Callable[[], Session] | None = None, ) -> None: @@ -200,6 +201,7 @@ def __init__( self._assembly_lock = threading.Lock() self._upload = upload # None → 真实对象存储上传 self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] + self._fetch_model3d = fetch_model3d # None → 下载 input.model_3d_url self._fetch_constraints = fetch_constraints # None → 查 project 全局约束 self._session_factory = session_factory # None → SessionLocal @@ -257,7 +259,6 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) """ if cons.directions > 1: logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) - master = (self._fetch_master or self._download_master)(input) # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字 desc_parts = [input.custom_prompt or ""] if cons.style: @@ -281,9 +282,33 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) **extra, ) progress: ProgressPort = _LogProgress() - generated = self._get_generator(_resolve_video_model(input.video_model)).generate( - card, action, master, progress, canvas=(cons.sprite_w, cons.sprite_h) - ) + canvas = (cons.sprite_w, cons.sprite_h) + + # ── 路线选择:这一步是 server 的事,不是引擎的(#122)──────────────── + # + # 判据就一条:这个造型有没有绑骨 3D 模型(character_data.outfits[].model_3d_url, + # 由 web 层读出来放进 input)。有 → 三渲二;没有 → 照旧 i2v。 + # + # **不静默回退。** 拿到了 model_3d_url 却下载不下来 / 渲不出来,就报错,不改走 + # i2v —— 两条路线的画风、成本、多朝向能力都不同,悄悄换一条等于让调用方拿着 + # 错误的前提做后续决定,而帧数、时长、成色全都正常,没有任何一道会红。 + model_url = (input.model_3d_url or "").strip() + if model_url: + rigged = (self._fetch_model3d or self._download_model3d)(model_url) + logger.info( + "[gen] 造型 %s 有 3D 资产(%d bytes),走三渲二", + input.outfit_id or "?", len(rigged), + ) + generated = self._get_generator( + _resolve_video_model(input.video_model)).generate_rendered( + card, action, rigged, progress, canvas=canvas + ) + else: + master = (self._fetch_master or self._download_master)(input) + generated = self._get_generator( + _resolve_video_model(input.video_model)).generate( + card, action, master, progress, canvas=canvas + ) upload = self._upload or self._upload_frame frames = [ @@ -349,6 +374,29 @@ def _assemble(self, video_model: str | None) -> CharacterGeneratorPort: ) return CharacterGenerator(strategies) + @staticmethod + def _build_render3d(): + """装三渲二的**渲帧**那一段。纯本地(node + playwright + three.js),零 API 成本。 + + 函数内 import:出帧台那套依赖只有这条路线用得着,在模块顶层要齐会让本来走 i2v + 的任务也因为它没装好而起不来。 + + **图生 3D 与绑骨那两段不在这里** —— 它们按次计费、每造型一次性,由 + ``render3d_assets.Render3DAssetBuilder`` 在请求路径之外做(带一道人工确认停点), + 产物 URL 落在 ``outfits[].model_3d_url`` 上。捆进来就等于一个 web 请求能顺手扣钱。 + """ + from windup_ai_engine.strategy.concrete import RenderFrameStrategy + from windup_framework.providers.render3d import LocalSpriteRenderProvider + + return RenderFrameStrategy(LocalSpriteRenderProvider()) + + def _download_model3d(self, url: str) -> bytes: + """取该造型的绑骨 3D 模型。走 ``fetch_own_media`` —— 与母版同一条受限通路 + (只允许本站对象存储的域名,防 SSRF)。模型动辄二三十 MB,但和母版一样是 + **一次性下载、进内存、喂引擎**,不落 ai_engine 的存储(它只吃 bytes)。 + """ + return fetch_own_media(url) + def _download_master(self, input: CharacterActionInput) -> bytes: if not input.reference_image_urls: raise ValueError("缺少母版:reference_image_urls 为空") 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 3c4aab8e..419b0f8e 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/model.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -78,6 +78,16 @@ class CharacterActionInput: # 取值域见 executor.ALLOWED_VIDEO_MODELS —— 只开放两个,因为每个模型的入参形状不同 # (image_list / input_reference / Fal 队列),全开等于把三套协议适配塞进一个改动。 video_model: str | None = None + # ── 三渲二(#192)──────────────────────────────────────────────────── + # + # 这次动作属于哪个造型。3D 资产挂在造型一级(#121),没有它就连"按造型定位资产" + # 都表达不出来。目前只被三渲二消费;推广成所有动作生成都按造型定位外观是 #253。 + outfit_id: str | None = None + # 该造型的绑骨 3D 模型 URL。**有值 = 这次走三渲二**;None = 照旧走 video_i2v。 + # + # 传 URL 而不是让编排层自己去查:与 reference_image_urls 同一口径 —— 取数在上层 + # 做完,"这次选了哪条路线"在入参上就可见,不是埋在某个分支里的隐式判断。 + model_3d_url: str | None = None # -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------ diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py new file mode 100644 index 00000000..3066a85f --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py @@ -0,0 +1,252 @@ +"""角色级 3D 资产的建造与落点 —— 三渲二里**花钱的那两段**。 + + 母版图 bytes ──①图生 3D──▶ 3D 模型 ──(人工确认)──▶ ②自动绑骨 ──▶ 绑骨模型 bytes + │ + 存进 CharacterAssetStore + │ + server 下次直接取出来喂 CharacterGeneratorPort.generate_rendered + +**渲帧那一段不在这里**,它在 ai_engine 的 ``RenderFrameStrategy`` 里(纯本地、零 API +成本)。花钱的留 server、只管渲的留引擎 —— 捆在一个 port 后面就看不出哪一步花钱。 + +━━ 为什么要有 CharacterAssetStore ━━ + +三段的成本结构完全不同:①图生 3D 与 ②自动绑骨按积分计费、**每造型一次性**,③渲帧 +零 API、每动作每朝向都免费。没有落点,①② 就得每个动作重跑一次 —— 一个造型做 10 个 +动作,成本差一个数量级(Refs 1024XEngineer/Windup#121)。所以这个 store 不是"存得 +整齐一点",它是这条路线成本优势能否成立的开关。 + +━━ 键取造型 id ━━ + +**键是造型(outfit)的稳定 id,不是角色 id、也不是 ``CharacterCard`` 上的任何字段。** + +- 挂造型一级(#121):外观挂在造型上(每个 outfit 自带 ``preview_url``),角色级只有一张 + 参考图,同一角色的不同造型共用不了一个 3D 模型。 +- 由调用方显式传入,不从 card 反查:card 由 executor 现搭,只有 name 和 desc 是可靠的, + 拿它上面别的字段当键会恒为 None —— 而单元测试直接构造 card,照样全绿。 +- 不用 ``name``:它不唯一(落库时甚至可以为 null,#123),拿它当键会让两个同名角色互相 + 复用彼此的模型 —— "看起来省钱、实际出错角色"的静默错误。 + +━━ 生成出来的 3D 模型要先给人看过才往下走 ━━ + +①② 之间有一道**人工确认停点**(:class:`ModelReviewGate`)。模型不可事后修改,坏模型 +只能重生成;一口气冲到绑骨+出帧的话,一个坏模型会连带浪费绑骨的积分和后面所有出帧, +而人要看完一整套序列帧才发现锅在最上游。停点放在图生 3D 之后、绑骨之前,是信息最全 +而花费最少的位置。待审期间 ① 的产物**单独存一份**,故反复调用不会重付那笔钱。 +""" +from __future__ import annotations + +import hashlib +import logging +import pathlib +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +from windup_ai_engine.ports import ProgressPort + +if TYPE_CHECKING: + from windup_framework.providers.render3d import ( + AutoRigProvider, + Model3DProvider, + RiggedModel, + ) + +logger = logging.getLogger(__name__) + +@runtime_checkable +class CharacterAssetStore(Protocol): + """角色级派生资产(绑好骨的 3D 模型)的落点。 + + 只有两个动作,且**必须是跨进程持久的** —— 进程内缓存等于每次重启都重付一遍 + ①②,而那正是本文件开头那笔一个数量级的差价。 + """ + + def get(self, key: str) -> bytes | None: ... + + def put(self, key: str, data: bytes) -> None: ... + + +class LocalDirAssetStore(CharacterAssetStore): + """落在本地目录的实现。 + + **部署注意:这个目录必须挂持久卷。** 落在容器可写层里的话,每次重建镜像/重启都会 + 清空,于是角色级资产退化成"每次部署后第一个动作重付 ①②"。要在多副本后端上用, + 应换成对象存储实现(同一个 Protocol,换注入即可)—— 那一步等 #121 拍板后做。 + """ + + def __init__(self, root: pathlib.Path) -> None: + self._root = root + self._root.mkdir(parents=True, exist_ok=True) + + def _path(self, key: str) -> pathlib.Path: + # key 里可能有 URL / 路径分隔符,哈希成扁平文件名;保留前缀便于人肉排查。 + digest = hashlib.sha256(key.encode()).hexdigest()[:32] + return self._root / f"rigged_{digest}.bin" + + def get(self, key: str) -> bytes | None: + p = self._path(key) + return p.read_bytes() if p.is_file() else None + + def put(self, key: str, data: bytes) -> None: + # 先写临时文件再 rename:半截文件被当成"资产已就绪"会让下一次渲染拿到坏模型, + # 而那时钱已经花完,错误却显形在出帧台("Bad glTF"),排查方向整个跑偏。 + p = self._path(key) + tmp = p.with_suffix(".part") + tmp.write_bytes(data) + tmp.replace(p) + + +class ModelAwaitingReview(RuntimeError): + """3D 模型已生成、**在等人看过点头**,还不能往下走。 + + 不是错误,是流程里的一个停点。故消息里带着"去哪看"和"怎么放行",让收到它的人 + 知道下一步该做什么,而不是以为管线坏了。 + """ + + def __init__(self, key: str, where: str, how: str) -> None: + super().__init__(f"3D 模型待人工确认(key={key})。看这里:{where};放行:{how}") + self.key = key + self.where = where + + +@runtime_checkable +class ModelReviewGate(Protocol): + """生成出来的 3D 模型,**必须先给人看过、点头,才允许往下花钱绑骨 / 出帧**。 + + 为什么这一道非要有:混元生成的 3D 模型**没法事后好好修改**,等于"生成即最终" —— + 拓扑、绑点、配件都在生成那一步定死。所以模型不合格时唯一的补救是重新生成,而不是 + 修它。若管线一口气从图生 3D 冲到绑骨+出帧,一个坏模型会连带浪费掉绑骨的 10 积分和 + 后面所有出帧,人还要看完一整套序列帧才发现问题出在最上游那一步。 + + 把停点放在图生 3D **之后、绑骨之前**,是因为这里是信息最全而花费最少的位置: + 模型已经在手上可以旋转着看,而下游的钱一分还没花。 + """ + + def submit(self, key: str, model: bytes, fmt: str) -> str: + """把待审模型交出去,返回"人该去哪看"的位置说明。""" + ... + + def is_approved(self, key: str) -> bool: + """人是否已点头。**不得自动变 True** —— 那就等于这道闸不存在。""" + ... + + +class LocalDirModelReview(ModelReviewGate): + """落本地目录 + 一个批准标记文件。 + + 放行方式刻意做成"人手动建一个标记文件",而不是任何形式的超时自动放行: + 自动放行的闸等于没有闸,只是把"没人看"伪装成"看过了"。 + """ + + def __init__(self, root: pathlib.Path) -> None: + self._root = root + self._root.mkdir(parents=True, exist_ok=True) + + def _stem(self, key: str) -> pathlib.Path: + return self._root / hashlib.sha256(key.encode()).hexdigest()[:32] + + def submit(self, key: str, model: bytes, fmt: str) -> str: + model_path = self._stem(key).with_suffix(f".{fmt.lower()}") + if not model_path.is_file(): # 已交过就别重写,人可能正在看 + tmp = model_path.with_suffix(".part") + tmp.write_bytes(model) + tmp.replace(model_path) + (self._stem(key).with_suffix(".key.txt")).write_text(key, encoding="utf-8") + return str(model_path) + + def is_approved(self, key: str) -> bool: + return self._stem(key).with_suffix(".approved").is_file() + + def approve(self, key: str) -> None: + """人看过之后放行(给 CLI / 运维用;管线自己**不会**调这个)。""" + self._stem(key).with_suffix(".approved").write_text("ok", encoding="utf-8") + + +class Render3DAssetBuilder: + """把①图生 3D + ②自动绑骨拼成"母版 → 该造型的绑骨模型",并落点复用。 + + **本类不渲帧。** 渲帧在 ai_engine 的 ``RenderFrameStrategy``(零 API 成本)。 + """ + + def __init__( + self, + model3d: Model3DProvider, + autorig: AutoRigProvider, + store: CharacterAssetStore, + review: ModelReviewGate, + *, + may_build_assets: bool = False, + ) -> None: + self._model3d = model3d + self._autorig = autorig + self._store = store + self._review = review + self._may_build_assets = may_build_assets + + def get(self, outfit_key: str) -> bytes | None: + """已就绪的绑骨模型;``None`` = 还没有。**不花钱、无副作用。** + + 这是 server 决定"这次调 generate 还是 generate_rendered"时用的那个判断 + (#122:判据由 server 出,不挂在引擎的 port 上)。 + """ + return self._store.get(outfit_key) if outfit_key else None + + def ensure(self, outfit_key: str, master: bytes, progress: ProgressPort) -> bytes: + """取该造型的绑骨模型;没有且获准时才现建。 + + 建一次约 ¥3.60(图生 3D 20 积分 + 绑骨 10 积分 × ¥0.12),**每造型一次性**。 + ``may_build_assets=False``(默认)时不建 —— 一个 web 请求不该顺手扣这笔钱, + 那正是"无人值守烧钱"。要放开就显式设 ``WINDUP_RENDER3D_ALLOW_SPEND``。 + """ + if not outfit_key: + raise ValueError( + "缺少造型 id,无法定位/复用该造型的 3D 资产。继续跑会让图生 3D + 绑骨" + "按动作重复计费(每造型一次性 → 每动作一次),故在花钱之前停下。" + ) + rigged_bytes = self._store.get(outfit_key) + if rigged_bytes is not None: + return rigged_bytes + if not self._may_build_assets: + raise ValueError( + f"造型 {outfit_key!r} 的 3D 资产未就绪,而本实例未获准建(建一次约 ¥3.60:" + "图生 3D 20 积分 + 绑骨 10 积分)。要现建请显式授权花钱," + "或先把资产备好,或改走 video_i2v。" + ) + return self._build(outfit_key, master, progress) + + # ── 内部 ───────────────────────────────────────────────────────────── + def _build(self, key: str, master: bytes, progress: ProgressPort) -> bytes: + """① 图生 3D →(人工确认)→ ② 绑骨。**按次计费,每造型一次性。** + + 中间那道人工确认是硬停点,原因见 :class:`ModelReviewGate`:模型不可事后修改, + 坏模型只能重生成,所以要在**花绑骨的钱之前**让人看一眼。 + """ + raw_key = f"raw:{key}" + + # 图生 3D 的产物单独存一份。**这不是冗余** —— 待审期间会有第二次、第三次调用走到 + # 这里,若不存,每次都要重付一遍图生 3D 的钱,而停点的本意恰恰是省钱。 + model = self._store.get(raw_key) + if model is None: + progress.step("assets", 0, 2, "造型级 3D 资产未就绪:图生 3D(按次计费)") + model = self._model3d.image_to_3d(master, want="GLB") + self._store.put(raw_key, model) + logger.info("图生 3D 产物已落点 key=%s bytes=%d", raw_key, len(model)) + + where = self._review.submit(key, model, "GLB") + if not self._review.is_approved(key): + progress.step("assets", 1, 2, "3D 模型已生成,等人工确认后才继续绑骨") + raise ModelAwaitingReview( + key, + where, + "旋转着看:把待审的 .glb 放到一个静态服务下用 three.js 的 GLTFLoader " + "+ OrbitControls 开(浏览器禁止 file:// 加载本地模型,必须走 http://localhost);" + "确认可用就在同目录建一个同名 .approved 空文件放行;" + "不合格则删掉待审模型重新生成 —— 混元的模型改不动,只能重生成", + ) + + progress.step("assets", 1, 2, "模型已确认,自动绑骨(按次计费,10 积分)") + rigged: RiggedModel = self._autorig.rig(model, want="GLB") + + # 存的是**绑骨后**的产物:它是渲帧真正要的那个,存中间的 model 等于下次还得再绑一次。 + self._store.put(key, rigged.data) + logger.info("造型级 3D 资产已落点 key=%s fmt=%s", key, rigged.fmt) + return rigged.data 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 63ba0dab..7c56e623 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import StreamingResponse -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from sqlalchemy import event from sqlalchemy.orm import Session @@ -31,7 +31,7 @@ from windup_common.result import Response from windup_framework.db import get_session -from windup_app.server.character.model import Character +from windup_app.server.character.model import Character, CharacterData from windup_app.server.orchestrator import task_repo from windup_app.server.orchestrator.service import service as generation_service from windup_app.server.orchestrator.model import ( @@ -182,6 +182,10 @@ class CharacterActionGenerateRequest(BaseModel): # 视频模型。None = 用部署默认(kling-v2-5-turbo)。取值域见 # orchestrator.executor.ALLOWED_VIDEO_MODELS;非法值在入口就报错,不到付费调用才失败。 video_model: str | None = None + # 这次动作属于哪个造型。给了才可能走三渲二 —— 3D 资产挂在造型一级(#121)。 + # 不给则照旧走 i2v(向后兼容:前端接上之前所有调用都是这样)。 + # 让**所有**动作生成都按造型定位外观是 #253,不在本改动范围内。 + outfit_id: str | None = None @model_validator(mode="after") def require_custom_prompt(self): @@ -252,6 +256,29 @@ def _get_character_or_raise( return character +def _outfit_model_3d_url(character: Character, outfit_id: str | None) -> str | None: + """这个造型有没有绑骨 3D 模型 —— **三渲二的唯一判据**(#122)。 + + 判据在这里读 DB 而不是做成 ai_engine port 上的查询:引擎只吃 bytes、不碰存储。 + 没给 ``outfit_id`` 就返回 None 照旧走 i2v,**不猜"那就用第一个造型吧"** —— 猜错 + 等于拿另一个造型的模型渲这次的动作,角色穿错衣服而帧数、时长、成色全部正常。 + """ + if not outfit_id: + return None + try: + data = CharacterData.model_validate(character.character_data or {}) + except ValidationError: + # 结构对不上就当没有资产:这一步只决定"走哪条路线",不该因为 character_data + # 里某个无关字段脏了就让整个动作生成起不来。走 i2v 仍然出得了帧。 + logger.warning("character %s 的 character_data 解析失败,三渲二判据按无资产处理", + character.id) + return None + outfit = next((o for o in data.outfits if o.id == outfit_id), None) + if outfit is None: + raise BizException(f"造型 {outfit_id!r} 不属于该角色", code=BizCode.NOT_FOUND) + return (outfit.model_3d_url or "").strip() or None + + def _validate_project_size(project: Project, width: int, height: int) -> None: """校验输入尺寸与项目约束是否一致;不一致则抛异常。""" if width != project.sprite_width or height != project.sprite_height: @@ -310,7 +337,7 @@ def submit_action_generation( """提交角色动作生成任务:建 PENDING 记录立即返回,实际生成后台跑。""" user_id = request.state.current_user.id _get_project_or_raise(session, body.project_id, user_id) - _get_character_or_raise(session, body.character_id, body.project_id) + character = _get_character_or_raise(session, body.character_id, body.project_id) input_data = CharacterActionInput( character_id=body.character_id, action_type=body.action_type, @@ -320,6 +347,10 @@ def submit_action_generation( reference_video_url=body.reference_video_url, reference_image_urls=body.reference_image_urls, num_frames=body.num_frames, + outfit_id=body.outfit_id, + # 路线选择在这里定死并写进入参,而不是留给编排层现查:这样"这次走的哪条路线" + # 在任务入参上就是可见的,排查时不用去猜当时 DB 是什么状态。 + model_3d_url=_outfit_model_3d_url(character, body.outfit_id), ) task = generation_service.generate_character_action( session, user_id=user_id, project_id=body.project_id, input=input_data, diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index 07cdbf3d..6016d933 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -68,6 +68,11 @@ class GenRoute(str, Enum): VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿) PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑) + # 三渲二:母版 → 图生 3D → 自动绑骨 → 套预设动作 → 渲 2D 序列帧。与上面两条有个 + # **结构性差异**:前两条由动作的物理性质唯一决定,这一条还取决于"该造型有没有 3D + # 资产"。所以它**不进 ROUTE_MATRIX** —— 由 server 读 DB 后直接调 + # ``CharacterGeneratorPort.generate_rendered``(#122)。 + RENDER_3D = "render_3d" class Facing(str, Enum): @@ -213,6 +218,9 @@ def _custom_needs_its_own_settings(self) -> ActionSpec: "传了不会生效" ) return self + # 这里**没有** ``route`` 字段:路线选择整个在 server —— 走不走三渲二取决于"这个造型 + # 有没有 3D 资产",那份数据在 DB 里,server 读完直接调 ``generate_rendered``。 + # 加一个零消费方的字段等于留一个"填了看起来会生效、实际没人读"的入参。 @model_validator(mode="before") @classmethod diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py index 7b441685..f72112ae 100644 --- a/backend/tests/test_ai_engine_skeleton.py +++ b/backend/tests/test_ai_engine_skeleton.py @@ -412,9 +412,12 @@ def test_genroute_only_lists_implemented_routes(): - 未来路线(三渲二渲染出帧)**不提前留位**,契约需求记在 Issue,随实现一起加成员。 枚举加成员是纯加法,不构成破坏性变更,所以"提前留位免得二次改形"不成立。 """ - assert {r.value for r in GenRoute} == {"video_i2v", "per_frame"} + assert {r.value for r in GenRoute} == {"video_i2v", "per_frame", "render_3d"} import windup_ai_engine.strategy as strat assert not hasattr(strat, "ProcIdleStrategy") + # render_3d 是随实现一起加的(RenderFrameStrategy + executor 装配),不是提前留位 —— + # 真实装配那条断言在 test_orchestrator_hardening 里。 + assert hasattr(strat, "RenderFrameStrategy") # ── 交付画布尺寸(2026-08-11 挣得)──────────────────────────────────────────── diff --git a/backend/tests/test_orchestrator_hardening.py b/backend/tests/test_orchestrator_hardening.py index 6634d062..51d0ee73 100644 --- a/backend/tests/test_orchestrator_hardening.py +++ b/backend/tests/test_orchestrator_hardening.py @@ -133,6 +133,12 @@ def test_real_generator_assembly_covers_every_declared_route(): 注入 generator 的测试走不到这条路径 —— 所以这条必须直接调真实装配。 """ + # RENDER_3D 这条路线的 provider 在 1024XEngineer/Windup#270。缺件时装配必然少一条, + # 断言必红 —— 那是缺件不是漏装,所以显式跳过而不是放宽断言。 + pytest.importorskip( + "windup_framework.providers.render3d", + reason="缺三渲二 provider 层(1024XEngineer/Windup#270),RENDER_3D 无法装配", + ) from windup_common.models import GenRoute from windup_app.server.orchestrator.executor import ActionTaskExecutor diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py new file mode 100644 index 00000000..8f1f0a8c --- /dev/null +++ b/backend/tests/test_render3d_route_and_assets.py @@ -0,0 +1,594 @@ +"""三渲二接进编排:谁选路线 + 造型级资产的成本性质。 + +这一片锁的核心不是"能跑通",而是**三类静默错误**: + + ① **路线永不可达。** 只要资产定位依赖一个 ``executor`` 从没赋过值的 card 字段, + 键就恒为 None、路线永远选不中,**而直接构造 card 的单元测试全绿**。所以本文件里 + 最重要的是端到端那条(``test_real_server_path_reaches_render3d_*``): + 从"造型上有 model_3d_url"一路走到"渲帧策略真的被调用",中间不许有测试替身 + 替代路线选择本身。 + + ② **静默回退。** 该走三渲二却悄悄出了一段 i2v,用户拿到画风 / 成本 / 多朝向能力 + 完全不同的产物,而帧数、时长、成色全部正常,没有任何一道会红。 + + ③ **资产没被复用。** 图生 3D + 绑骨按动作重付,一个造型 10 个动作贵 10 倍。 + 这条只会表现为"有点慢 + 账单变多",不会有任何报错(Refs #121)。 +""" +from __future__ import annotations + +import io +import pathlib + +import pytest +from PIL import Image + +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.strategy.concrete import RenderFrameStrategy, VideoFrameStrategy +from windup_app.server.orchestrator.executor import ActionTaskExecutor, ProjectConstraints +from windup_app.server.orchestrator.model import ActionType as InputActionType +from windup_app.server.orchestrator.model import CharacterActionInput +from windup_app.server.orchestrator.render3d_assets import ( + LocalDirAssetStore, + LocalDirModelReview, + ModelAwaitingReview, + Render3DAssetBuilder, +) +from windup_common.models import ( + ActionSpec, + ActionType, + CharacterCard, + Facing, + GenRoute, + Stylize, +) +try: + from windup_framework.providers.render3d import ( + PresetMotion, + RiggedModel, + RigInfo, + SpriteSequence, + SpriteSheet, + ) +except ModuleNotFoundError as exc: + # 不静默通过:本文件的替身要**真** provider 的数据类型,自己糊一份等于测"我的假数据 + # 长得像帧"。缺件时整体跳过并把缺的模块名报出来。 + pytest.skip( + f"缺 {exc.name}(三渲二 provider 层,见 1024XEngineer/Windup#270);" + "该 PR 合入前本文件整体跳过。", + allow_module_level=True, + ) + + +def _png(w: int = 64, h: int = 96) -> bytes: + """一张带主体的真 RGBA PNG —— 假帧必须能被 _lastmile 真的解码/对齐, + 否则测的是"我的假数据长得像帧"而不是管线。""" + im = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(24, 40): + im.putpixel((x, y), (200, 60, 60, 255)) + buf = io.BytesIO() + im.save(buf, "PNG") + return buf.getvalue() + + +class _NullProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + pass + + +class _SpyProgress: + def __init__(self) -> None: + self.notes: list[str] = [] + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + self.notes.append(note) + + +# ── 假三段(记调用次数,用来证明"每造型一次性")────────────────────────────── + + +class _FakeModel3D: + def __init__(self) -> None: + self.calls = 0 + + def image_to_3d(self, master, *, want="GLB", extra_views=None) -> bytes: + self.calls += 1 + return b"GLB-model-bytes" + + +class _FakeAutoRig: + def __init__(self) -> None: + self.calls = 0 + + @property + def preset_motions(self): + return {"walk": PresetMotion(name="walk", motion_type=1)} + + def rig(self, model, *, want="GLB", motion=None) -> RiggedModel: + self.calls += 1 + return RiggedModel(data=b"RIGGED-bytes", fmt="GLB") + + +def _sheet(directions: tuple[str, ...], n_frames: int) -> SpriteSheet: + return SpriteSheet( + clip="walk", + duration_s=1.0, + sample_times=[i / n_frames for i in range(n_frames)], + sequences=[ + SpriteSequence(direction=d, camera_yaw=0.0, frames=[_png()] * n_frames) + for d in directions + ], + rig=RigInfo(bones=28, skinned_meshes=1, vertices=100, root_bone="Hips", loader="glb"), + available_clips={"walk": 1.0}, + ) + + +class _FakeRenderer: + """出帧台替身。**只有它是假的** —— 真出帧台要 node + playwright + three.js, + CI 里跑不了;而路线选择、策略装配、编排接线全部走真代码。""" + + def __init__(self, directions=("e", "n", "w", "s")) -> None: + self.calls = 0 + self.last_model: bytes | None = None + self._directions = directions + self.last_size: tuple[int, int] | None = None + + def render(self, rigged_model, *, clip=None, directions=4, frames=12, + size=(1536, 2560), material="cel") -> SpriteSheet: + self.calls += 1 + self.last_model = rigged_model + self.last_size = size + return _sheet(self._directions, frames) + + +class _AutoApproveReview: + """测试替身:直接放行。**只用于不测这道闸的用例** —— 闸本身另有专门用例。""" + + def __init__(self) -> None: + self.submitted: list[str] = [] + + def submit(self, key: str, model: bytes, fmt: str) -> str: + self.submitted.append(key) + return f"/{key}.{fmt.lower()}" + + def is_approved(self, key: str) -> bool: + return True + + +def _builder(tmp_path: pathlib.Path, may_build=True, review=None): + """``may_build`` 缺省 True:多数用例要验建资产那一支的行为,而这里的三段都是假的、 + 不花真钱。**默认档(False)的行为另有专门用例**,见"花钱要有人点头"那一节。 + + ``review`` 缺省自动放行,同理 —— 人工确认停点的行为另有专门用例。""" + m, r = _FakeModel3D(), _FakeAutoRig() + return Render3DAssetBuilder( + model3d=m, autorig=r, store=LocalDirAssetStore(tmp_path), + review=review or _AutoApproveReview(), + may_build_assets=may_build, + ), m, r + + +def _card() -> CharacterCard: + return CharacterCard(name="仙月", desc="美少女") + + +def _spec(**kw) -> ActionSpec: + kw.setdefault("action", ActionType.WALK) + kw.setdefault("n_frames", 4) + kw.setdefault("stylize", Stylize.NONE) + return ActionSpec(**kw) + + +OUTFIT = "outfit-hanfu-01" + + +# ══ ① 端到端:走真实 server 路径能选到三渲二 ═══════════════════════════════ +# +# 这一节是本文件存在的首要理由,别把它替身化。 + + +def _real_generator(renderer) -> CharacterGenerator: + """真的 CharacterGenerator + 真的 RenderFrameStrategy,只有出帧台是假的。""" + return CharacterGenerator({ + GenRoute.VIDEO_I2V: VideoFrameStrategy(video=None, matte=None), + GenRoute.RENDER_3D: RenderFrameStrategy(renderer), + }) + + +def test_real_server_path_reaches_render3d_when_the_outfit_has_a_model(): + """造型带 model_3d_url → 编排层真的走到渲帧策略,而不是 i2v。 + + 这条钉的正是"路线永不可达"那个缺陷:它只断言**编排层自己**选对了路线, + 没有任何替身代替这一步。 + """ + renderer = _FakeRenderer() + executor = ActionTaskExecutor( + generator=_real_generator(renderer), + upload=lambda _png: "https://cdn.example.com/f.png", + fetch_master=lambda _input: pytest.fail("走三渲二不该去下载母版"), + fetch_model3d=lambda url: b"RIGGED-bytes", + fetch_constraints=lambda *_: ProjectConstraints(sprite_w=64, sprite_h=64), + ) + out = executor._produce_action( + CharacterActionInput( + character_id=1, + action_type=InputActionType.WALK, + num_frames=4, + outfit_id=OUTFIT, + model_3d_url="https://cdn.example.com/outfits/hanfu.glb", + ), + ProjectConstraints(sprite_w=64, sprite_h=64), + ) + + assert renderer.calls == 1, "三渲二没被走到 —— 路线选择又断了" + assert renderer.last_model == b"RIGGED-bytes", "喂给出帧台的不是取回来的那个模型" + assert len(out["frames"]) == 4 + + +def test_real_server_path_stays_on_i2v_when_the_outfit_has_no_model(): + """没有 model_3d_url 就照旧走 i2v —— 三渲二不是默认路线,也不该抢别人的活。""" + renderer = _FakeRenderer() + executor = ActionTaskExecutor( + generator=_real_generator(renderer), + upload=lambda _png: "https://cdn.example.com/f.png", + fetch_master=lambda _input: _png(), + fetch_model3d=lambda url: pytest.fail("没有 3D 资产却去取模型了"), + fetch_constraints=lambda *_: ProjectConstraints(sprite_w=64, sprite_h=64), + ) + with pytest.raises(Exception): + # VideoFrameStrategy 的 provider 是 None,走到它必然炸 —— 这正是本用例要的: + # 证明它走的是 i2v 那一支。真正的 i2v 行为在别处有用例。 + executor._produce_action( + CharacterActionInput( + character_id=1, action_type=InputActionType.WALK, num_frames=4, + outfit_id=OUTFIT, model_3d_url=None, + ), + ProjectConstraints(sprite_w=64, sprite_h=64), + ) + assert renderer.calls == 0 + + +def test_web_layer_reads_the_outfit_model_url_into_the_task_input(auth_client, monkeypatch): + """Web 层要把造型上的 model_3d_url **真的填进任务入参**。 + + 这一步是上次那个缺陷的落点:字段从没被赋过值,而下游全部正常运行、只是永远 + 走不到三渲二。所以要断言的是"值到底进没进入参",不是"端点返回 200"。 + """ + from windup_app.web.api import generation as gen_api + + project = auth_client.post("/projects", json={ + "project_name": "三渲二", "character_perspective": 1, "directional_movement": 2, + "sprite_width": 64, "sprite_height": 64, + }).json()["data"] + character = auth_client.post("/characters", json={ + "project_id": project["id"], "workflow_run_id": 1, "name": "勇者", + "character_data": { + "version": 1, + "outfits": [{ + "id": OUTFIT, "name": "汉服", + "model_3d_url": "https://cdn.example.com/outfits/hanfu.glb", + "actions": [], + }], + }, + }).json()["data"] + + captured: list[CharacterActionInput] = [] + monkeypatch.setattr( + gen_api, "_dispatch_after_commit", + lambda session, target, task_id, input_data, project_id: captured.append(input_data), + ) + resp = auth_client.post("/generation/action", json={ + "project_id": project["id"], "character_id": character["id"], + "action_type": "walk", "num_frames": 4, "outfit_id": OUTFIT, + }) + + assert resp.json()["data"] is not None, resp.json() + assert captured, "任务没被派发,拿不到入参" + assert captured[0].model_3d_url == "https://cdn.example.com/outfits/hanfu.glb" + assert captured[0].outfit_id == OUTFIT + + +def test_web_layer_does_not_guess_an_outfit_when_none_is_given(auth_client, monkeypatch): + """没给 outfit_id 就不许挑一个造型顶上 —— 猜错等于拿另一套衣服渲这次的动作, + 而帧数、时长、成色全部正常,没有任何一道会红。""" + from windup_app.web.api import generation as gen_api + + project = auth_client.post("/projects", json={ + "project_name": "三渲二", "character_perspective": 1, "directional_movement": 2, + "sprite_width": 64, "sprite_height": 64, + }).json()["data"] + character = auth_client.post("/characters", json={ + "project_id": project["id"], "workflow_run_id": 1, "name": "勇者", + "character_data": {"version": 1, "outfits": [{ + "id": OUTFIT, "name": "汉服", + "model_3d_url": "https://cdn.example.com/outfits/hanfu.glb", "actions": [], + }]}, + }).json()["data"] + + captured: list[CharacterActionInput] = [] + monkeypatch.setattr( + gen_api, "_dispatch_after_commit", + lambda session, target, task_id, input_data, project_id: captured.append(input_data), + ) + auth_client.post("/generation/action", json={ + "project_id": project["id"], "character_id": character["id"], + "action_type": "walk", "num_frames": 4, + }) + + assert captured and captured[0].model_3d_url is None + + +def test_unknown_outfit_id_is_rejected_not_ignored(auth_client): + """造型 id 对不上要报错。静默当成"没有资产"会让用户以为三渲二不可用, + 实际是他把 id 打错了。""" + project = auth_client.post("/projects", json={ + "project_name": "三渲二", "character_perspective": 1, "directional_movement": 2, + "sprite_width": 64, "sprite_height": 64, + }).json()["data"] + character = auth_client.post("/characters", json={ + "project_id": project["id"], "workflow_run_id": 1, "name": "勇者", + "character_data": {"version": 1, "outfits": []}, + }).json()["data"] + + resp = auth_client.post("/generation/action", json={ + "project_id": project["id"], "character_id": character["id"], + "action_type": "walk", "num_frames": 4, "outfit_id": "不存在的造型", + }) + assert resp.json()["code"] == 404 + + +# ══ ② 引擎侧:路线选择不归它管 ════════════════════════════════════════════ + + +def test_engine_has_no_route_field_to_be_told_which_route_to_take(): + """``ActionSpec.route`` 已删(#122):路线选择整个移到 server,这个字段零消费方。 + + 留着一个"填了看起来会生效、实际没人读"的入参,正是本仓反复吃过的那类错。 + """ + with pytest.raises(Exception): + ActionSpec(action=ActionType.WALK, n_frames=4, route=GenRoute.RENDER_3D) + + +def test_render3d_is_not_in_the_route_matrix(): + """三渲二不进 ROUTE_MATRIX —— 那张表的前提是"路线由动作物理性质唯一决定", + 而走不走三渲二取决于造型有没有 3D 资产。塞进去就破了它的前提。""" + from windup_ai_engine.strategy.base import ROUTE_MATRIX + + assert GenRoute.RENDER_3D not in ROUTE_MATRIX.values() + + +def test_generate_rendered_raises_when_the_route_is_not_assembled(): + """没装 RENDER_3D 的 strategy 就在边界上炸,不静默回退到 i2v。""" + gen = CharacterGenerator({GenRoute.VIDEO_I2V: VideoFrameStrategy(video=None, matte=None)}) + with pytest.raises(NotImplementedError, match="render_3d"): + gen.generate_rendered(_card(), _spec(), b"RIGGED", _NullProgress()) + + +def test_empty_model_bytes_raise_before_the_render_stage(): + """空模型必须在策略入口炸。放下去的话出帧台会报一句"Bad glTF",排查方向全跑偏。""" + renderer = _FakeRenderer() + with pytest.raises(ValueError, match="空的绑骨模型"): + RenderFrameStrategy(renderer).derive(_card(), _spec(), b"", _NullProgress()) + assert renderer.calls == 0 + + +# ══ ③ 多朝向:如实上报,不闷掉 ════════════════════════════════════════════ + + +def test_requested_facing_picks_the_matching_direction(): + renderer = _FakeRenderer(directions=("e", "n")) + spy = _SpyProgress() + RenderFrameStrategy(renderer).derive( + _card(), _spec(facing=Facing.FRONT), b"RIGGED", spy, + ) + assert any("朝向 n" in n or "只带 n" in n for n in spy.notes), spy.notes + + +def test_missing_direction_raises_instead_of_handing_back_another(): + """出帧台没出请求的朝向就报错。换一个交出去 = 角色朝反方向走,而没有任何一道会红。""" + renderer = _FakeRenderer(directions=("w", "s")) + with pytest.raises(ValueError, match="没有产出朝向"): + RenderFrameStrategy(renderer).derive(_card(), _spec(), b"RIGGED", _NullProgress()) + + +def test_extra_directions_are_reported_not_silently_dropped(): + """多渲出来的朝向零成本、但出参装不下 —— 这笔浪费要**可见**。""" + spy = _SpyProgress() + RenderFrameStrategy(_FakeRenderer(directions=("e", "n", "w", "s"))).derive( + _card(), _spec(), b"RIGGED", spy, + ) + assert any("零成本可用但当前契约装不下" in n for n in spy.notes), spy.notes + + +def test_render_uses_the_measured_portrait_canvas(): + """出帧台画布用挣来的那个口径(竖图),不是随手一个方形。""" + from windup_framework.providers.render3d import RENDER_SIZE + + renderer = _FakeRenderer() + RenderFrameStrategy(renderer).derive(_card(), _spec(), b"RIGGED", _NullProgress()) + assert renderer.last_size == RENDER_SIZE + + +def test_empty_render_output_raises(): + class _EmptyRenderer(_FakeRenderer): + def render(self, rigged_model, **kw) -> SpriteSheet: + return SpriteSheet( + clip="walk", duration_s=1.0, sample_times=[], + sequences=[SpriteSequence(direction="e", camera_yaw=0.0, frames=[])], + rig=RigInfo(bones=1, skinned_meshes=1, vertices=1, root_bone="Hips", + loader="glb"), + available_clips={"walk": 1.0}, + ) + + with pytest.raises(ValueError, match="未产出任何帧"): + RenderFrameStrategy(_EmptyRenderer()).derive( + _card(), _spec(), b"RIGGED", _NullProgress(), + ) + + +# ══ ④ 成本性质:造型级资产每造型一次性 ════════════════════════════════════ + + +def test_second_action_reuses_assets_and_pays_nothing_extra(tmp_path): + builder, m, r = _builder(tmp_path) + builder.ensure(OUTFIT, _png(), _NullProgress()) + builder.ensure(OUTFIT, _png(), _NullProgress()) + assert (m.calls, r.calls) == (1, 1), "图生 3D / 绑骨按动作重付了" + + +def test_assets_survive_a_new_builder_instance(tmp_path): + """落点必须跨进程持久 —— 进程内缓存等于每次重启都重付一遍。""" + b1, m1, r1 = _builder(tmp_path) + b1.ensure(OUTFIT, _png(), _NullProgress()) + b2, m2, r2 = _builder(tmp_path) + b2.ensure(OUTFIT, _png(), _NullProgress()) + assert (m2.calls, r2.calls) == (0, 0) + + +def test_different_outfits_do_not_share_a_model(tmp_path): + """不同造型是不同外观,共用一个 3D 模型 = 拿错衣服渲,而没有任何一道会红。""" + builder, m, r = _builder(tmp_path) + builder.ensure(OUTFIT, _png(), _NullProgress()) + builder.ensure("outfit-armor-02", _png(), _NullProgress()) + assert (m.calls, r.calls) == (2, 2) + + +def test_missing_outfit_key_raises_before_spending(tmp_path): + builder, m, r = _builder(tmp_path) + with pytest.raises(ValueError, match="缺少造型 id"): + builder.ensure("", _png(), _NullProgress()) + assert (m.calls, r.calls) == (0, 0) + + +def test_get_costs_nothing(tmp_path): + """``get`` 是 server 选路线时调的,必须不花钱、无副作用。""" + builder, m, r = _builder(tmp_path) + assert builder.get(OUTFIT) is None + assert (m.calls, r.calls) == (0, 0) + + +# ══ ⑤ 花钱要有人点头 ═════════════════════════════════════════════════════ + + +def test_default_posture_refuses_to_build_assets(tmp_path): + """默认不授权花钱:一个 web 请求不该顺手扣 ¥3.60。""" + builder, m, r = _builder(tmp_path, may_build=False) + with pytest.raises(ValueError, match="未获准建"): + builder.ensure(OUTFIT, _png(), _NullProgress()) + assert (m.calls, r.calls) == (0, 0) + + +def test_default_posture_still_serves_outfits_that_already_have_assets(tmp_path): + """已有资产的造型照常出帧 —— 默认档挡的是"建",不是"用"。""" + b1, _, _ = _builder(tmp_path, may_build=True) + b1.ensure(OUTFIT, _png(), _NullProgress()) + b2, m2, r2 = _builder(tmp_path, may_build=False) + assert b2.ensure(OUTFIT, _png(), _NullProgress()) == b"RIGGED-bytes" + assert (m2.calls, r2.calls) == (0, 0) + + +# ══ ⑥ 生成的 3D 模型必须先给人看过才往下走 ═══════════════════════════════ + + +class _NeverApprove(_AutoApproveReview): + def is_approved(self, key: str) -> bool: + return False + + +def test_model_awaits_review_before_paying_for_rigging(tmp_path): + """停点在图生 3D 之后、绑骨之前:信息最全而花费最少的位置。""" + builder, m, r = _builder(tmp_path, review=_NeverApprove()) + with pytest.raises(ModelAwaitingReview): + builder.ensure(OUTFIT, _png(), _NullProgress()) + assert (m.calls, r.calls) == (1, 0), "还没人点头就把绑骨的钱花了" + + +def test_waiting_for_review_does_not_repay_image_to_3d(tmp_path): + """待审期间反复调用不该重付图生 3D —— 停点的本意恰恰是省钱。""" + builder, m, _ = _builder(tmp_path, review=_NeverApprove()) + for _ in range(3): + with pytest.raises(ModelAwaitingReview): + builder.ensure(OUTFIT, _png(), _NullProgress()) + assert m.calls == 1 + + +def test_review_never_self_approves(tmp_path): + """放行只能靠人手动建标记文件。超时自动放行的闸等于没有闸。""" + gate = LocalDirModelReview(tmp_path / "review") + gate.submit(OUTFIT, b"GLB", "GLB") + assert gate.is_approved(OUTFIT) is False + gate.approve(OUTFIT) + assert gate.is_approved(OUTFIT) is True + + +def test_after_approval_it_proceeds_and_reuses_the_stored_model(tmp_path): + """人点头之后继续绑骨,且**不重付**图生 3D(待审期间那份已经存下来了)。""" + store = LocalDirAssetStore(tmp_path) + gate = LocalDirModelReview(tmp_path / "review") + m, r = _FakeModel3D(), _FakeAutoRig() + builder = Render3DAssetBuilder( + model3d=m, autorig=r, store=store, review=gate, may_build_assets=True, + ) + with pytest.raises(ModelAwaitingReview): + builder.ensure(OUTFIT, _png(), _NullProgress()) + gate.approve(OUTFIT) + assert builder.ensure(OUTFIT, _png(), _NullProgress()) == b"RIGGED-bytes" + assert (m.calls, r.calls) == (1, 1) + + +# ══ ⑦ 分区动量:整幅指标的盲区 ═══════════════════════════════════════════ + + +def test_limb_motion_catches_a_frozen_region_that_whole_frame_metrics_miss(): + """一半肢体冻着、另一半在动 —— motion_scale 与死帧全部正常,只有分区动量看得见。 + + 这是自动绑骨漏认一条肢体的典型产物:那块网格没有骨骼驱动,每帧同姿势。 + """ + from windup_ai_engine.slicing.quality import ( + dead_frame_indices, + limb_motion, + motion_scale, + ) + + # 左半永远不变,右半逐帧移动 + frames = [] + for i in range(12): + im = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(8, 24): # 左半:固定 + im.putpixel((x, y), (200, 60, 60, 255)) + for x in range(40 + (i % 4), 52 + (i % 4)): # 右半:动 + im.putpixel((x, y), (60, 60, 200, 255)) + frames.append(im) + + assert motion_scale(frames) > 0.5, "整幅指标应当认为这段在动" + assert len(dead_frame_indices(frames)) < len(frames) // 2, "整幅判据也不会报成死帧" + + lm = limb_motion(frames) + left = [v for k, v in lm.items() if k.endswith("左") and isinstance(v, float)] + right = [v for k, v in lm.items() if k.endswith("右") and isinstance(v, float)] + assert max(left) < 0.02, f"冻结的左半占比应当接近 0,实际 {left}" + assert min(right) > 0.1, f"在动的右半占比应当显著为正,实际 {right}" + assert "左" in lm["still"], f"最静区应当在左半,实际 {lm['still']}" + + +def test_limb_motion_summary_keys_are_not_mistaken_for_regions(): + """``still`` 只能指向真实分区,不能挑中自己。 + + 把汇总键写进字典后才算最小值的话,它(值通常比任何区都小)会被挑成"最静区", + 报出一个不存在的区名 —— 一个只在数据上体现、不会报错的错。顺带钉住占比归一。 + """ + from windup_ai_engine.slicing.quality import limb_motion + + frames = [Image.new("RGBA", (64, 96), (0, 0, 0, 0)) for _ in range(4)] + for i, im in enumerate(frames): + for y in range(20, 80): + for x in range(10 + i, 30 + i): + im.putpixel((x, y), (200, 60, 60, 255)) + lm = limb_motion(frames) + assert lm["still"] != "still", f"still 挑中了汇总键:{lm['still']}" + assert lm["still"] in lm and isinstance(lm[lm["still"]], float) + shares = [v for k, v in lm.items() if k != "still"] + # 容差取舍入精度:各区各自 round 到 3 位,6 个区最多累积 6×0.0005 的误差。 + # 不写 1e-6 —— 那样断言的是"没做舍入",而不是"归一化对了"。 + assert abs(sum(shares) - 1.0) < 0.01, f"各区占比应当归一,实际和 {sum(shares)}" From 9ff2ddb8234eaf0934c5963b005e67a9ba4069b9 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Thu, 13 Aug 2026 15:58:13 +0800 Subject: [PATCH 2/7] =?UTF-8?q?test(quality):=20=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E5=8A=A8=E9=87=8F=E7=94=A8=E4=BE=8B=E7=A7=BB=E5=87=BA=E4=B8=89?= =?UTF-8?q?=E6=B8=B2=E4=BA=8C=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这两条只用 PIL 和 slicing.quality,与三渲二 provider 无关。留在 test_render3d_route_and_assets.py 里会被那个文件的整体 skip 一并带走, 于是本 PR 新增的这项读数在 provider 合入前一条断言都不跑。 --- .../tests/test_master_check_and_quality.py | 58 +++++++++++++++++++ .../tests/test_render3d_route_and_assets.py | 58 ------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/backend/tests/test_master_check_and_quality.py b/backend/tests/test_master_check_and_quality.py index cc3bfd43..33e1f707 100644 --- a/backend/tests/test_master_check_and_quality.py +++ b/backend/tests/test_master_check_and_quality.py @@ -234,3 +234,61 @@ def test_quality_fields_are_independent(): 两者若能互推,这一组断言不可能同时成立。""" q = ActionQuality(motion_scale=0.0, dead_frames=(), loop_seam=None) assert q.motion_scale == 0.0 and q.dead_frames == () and q.loop_seam is None + + +# ── 分区动量:整幅指标的盲区 ────────────────────────────────────────────── + + +def test_limb_motion_catches_a_frozen_region_that_whole_frame_metrics_miss(): + """一半肢体冻着、另一半在动 —— motion_scale 与死帧全部正常,只有分区动量看得见。 + + 这是自动绑骨漏认一条肢体的典型产物:那块网格没有骨骼驱动,每帧同姿势。 + """ + from windup_ai_engine.slicing.quality import ( + dead_frame_indices, + limb_motion, + motion_scale, + ) + + # 左半永远不变,右半逐帧移动 + frames = [] + for i in range(12): + im = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(8, 24): # 左半:固定 + im.putpixel((x, y), (200, 60, 60, 255)) + for x in range(40 + (i % 4), 52 + (i % 4)): # 右半:动 + im.putpixel((x, y), (60, 60, 200, 255)) + frames.append(im) + + assert motion_scale(frames) > 0.5, "整幅指标应当认为这段在动" + assert len(dead_frame_indices(frames)) < len(frames) // 2, "整幅判据也不会报成死帧" + + lm = limb_motion(frames) + left = [v for k, v in lm.items() if k.endswith("左") and isinstance(v, float)] + right = [v for k, v in lm.items() if k.endswith("右") and isinstance(v, float)] + assert max(left) < 0.02, f"冻结的左半占比应当接近 0,实际 {left}" + assert min(right) > 0.1, f"在动的右半占比应当显著为正,实际 {right}" + assert "左" in lm["still"], f"最静区应当在左半,实际 {lm['still']}" + + +def test_limb_motion_summary_keys_are_not_mistaken_for_regions(): + """``still`` 只能指向真实分区,不能挑中自己。 + + 把汇总键写进字典后才算最小值的话,它(值通常比任何区都小)会被挑成"最静区", + 报出一个不存在的区名 —— 一个只在数据上体现、不会报错的错。顺带钉住占比归一。 + """ + from windup_ai_engine.slicing.quality import limb_motion + + frames = [Image.new("RGBA", (64, 96), (0, 0, 0, 0)) for _ in range(4)] + for i, im in enumerate(frames): + for y in range(20, 80): + for x in range(10 + i, 30 + i): + im.putpixel((x, y), (200, 60, 60, 255)) + lm = limb_motion(frames) + assert lm["still"] != "still", f"still 挑中了汇总键:{lm['still']}" + assert lm["still"] in lm and isinstance(lm[lm["still"]], float) + shares = [v for k, v in lm.items() if k != "still"] + # 容差取舍入精度:各区各自 round 到 3 位,6 个区最多累积 6×0.0005 的误差。 + # 不写 1e-6 —— 那样断言的是"没做舍入",而不是"归一化对了"。 + assert abs(sum(shares) - 1.0) < 0.01, f"各区占比应当归一,实际和 {sum(shares)}" diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index 8f1f0a8c..28b25e29 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -534,61 +534,3 @@ 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_limb_motion_catches_a_frozen_region_that_whole_frame_metrics_miss(): - """一半肢体冻着、另一半在动 —— motion_scale 与死帧全部正常,只有分区动量看得见。 - - 这是自动绑骨漏认一条肢体的典型产物:那块网格没有骨骼驱动,每帧同姿势。 - """ - from windup_ai_engine.slicing.quality import ( - dead_frame_indices, - limb_motion, - motion_scale, - ) - - # 左半永远不变,右半逐帧移动 - frames = [] - for i in range(12): - im = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) - for y in range(20, 80): - for x in range(8, 24): # 左半:固定 - im.putpixel((x, y), (200, 60, 60, 255)) - for x in range(40 + (i % 4), 52 + (i % 4)): # 右半:动 - im.putpixel((x, y), (60, 60, 200, 255)) - frames.append(im) - - assert motion_scale(frames) > 0.5, "整幅指标应当认为这段在动" - assert len(dead_frame_indices(frames)) < len(frames) // 2, "整幅判据也不会报成死帧" - - lm = limb_motion(frames) - left = [v for k, v in lm.items() if k.endswith("左") and isinstance(v, float)] - right = [v for k, v in lm.items() if k.endswith("右") and isinstance(v, float)] - assert max(left) < 0.02, f"冻结的左半占比应当接近 0,实际 {left}" - assert min(right) > 0.1, f"在动的右半占比应当显著为正,实际 {right}" - assert "左" in lm["still"], f"最静区应当在左半,实际 {lm['still']}" - - -def test_limb_motion_summary_keys_are_not_mistaken_for_regions(): - """``still`` 只能指向真实分区,不能挑中自己。 - - 把汇总键写进字典后才算最小值的话,它(值通常比任何区都小)会被挑成"最静区", - 报出一个不存在的区名 —— 一个只在数据上体现、不会报错的错。顺带钉住占比归一。 - """ - from windup_ai_engine.slicing.quality import limb_motion - - frames = [Image.new("RGBA", (64, 96), (0, 0, 0, 0)) for _ in range(4)] - for i, im in enumerate(frames): - for y in range(20, 80): - for x in range(10 + i, 30 + i): - im.putpixel((x, y), (200, 60, 60, 255)) - lm = limb_motion(frames) - assert lm["still"] != "still", f"still 挑中了汇总键:{lm['still']}" - assert lm["still"] in lm and isinstance(lm[lm["still"]], float) - shares = [v for k, v in lm.items() if k != "still"] - # 容差取舍入精度:各区各自 round 到 3 位,6 个区最多累积 6×0.0005 的误差。 - # 不写 1e-6 —— 那样断言的是"没做舍入",而不是"归一化对了"。 - assert abs(sum(shares) - 1.0) < 0.01, f"各区占比应当归一,实际和 {sum(shares)}" From 204f238c4928e95c22ac3a7e51172d4cff500753 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Thu, 13 Aug 2026 16:23:34 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(render3d):=20=E6=B8=B2=E5=B8=A7?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=BB=B6=E8=BF=9F=E5=88=B0=E8=A2=AB=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=97=B6=E6=89=8D=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #241 合入后 rebase 撞出来的:它给装配表加了一条断言,要求每个 GenRoute 成员都被装配, 而本 PR 新增的 RENDER_3D 依赖 #270 的 provider 模块——那个模块在这条分支上还不存在, 装配期就 import 会让本来走 i2v 的任务也起不来。 改成装配表里放一个惰性策略:真走到三渲二那条路线时才 import 出帧台依赖。 断言因此仍然成立(表里确实有这个键),而缺 provider 也不影响其余路线。 两个状态都验过:不带 #270 时 496 passed / 2 skipped;把 #270 的 provider 层铺进工作区 再跑,591 passed / 14 skipped,惰性策略能真的实例化出 RenderFrameStrategy。 Refs 1024XEngineer/Windup#192 --- .../server/orchestrator/executor.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) 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 300e69c4..1491b21b 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -365,6 +365,7 @@ def _assemble(self, video_model: str | None) -> CharacterGeneratorPort: strategies = { GenRoute.VIDEO_I2V: VideoFrameStrategy(video, self._matte), GenRoute.PER_FRAME: PerFrameStrategy(self._image, self._matte), + GenRoute.RENDER_3D: self._build_render3d(), } missing = set(GenRoute) - set(strategies) if missing: @@ -376,19 +377,33 @@ def _assemble(self, video_model: str | None) -> CharacterGeneratorPort: @staticmethod def _build_render3d(): - """装三渲二的**渲帧**那一段。纯本地(node + playwright + three.js),零 API 成本。 + """三渲二的**渲帧**那一段。纯本地(node + playwright + three.js),零 API 成本。 - 函数内 import:出帧台那套依赖只有这条路线用得着,在模块顶层要齐会让本来走 i2v - 的任务也因为它没装好而起不来。 + 真被请求时才 import 出帧台那套依赖:它只有这条路线用得着,装配期就要齐会让本来 + 走 i2v 的任务也因为它没配好而起不来。 **图生 3D 与绑骨那两段不在这里** —— 它们按次计费、每造型一次性,由 ``render3d_assets.Render3DAssetBuilder`` 在请求路径之外做(带一道人工确认停点), 产物 URL 落在 ``outfits[].model_3d_url`` 上。捆进来就等于一个 web 请求能顺手扣钱。 """ - from windup_ai_engine.strategy.concrete import RenderFrameStrategy - from windup_framework.providers.render3d import LocalSpriteRenderProvider + from windup_ai_engine.strategy.base import DerivationStrategy + from windup_common.models import GenRoute + + class _LazyRenderStrategy(DerivationStrategy): + route = GenRoute.RENDER_3D + + def __init__(self) -> None: + self._inner: DerivationStrategy | None = None + + def derive(self, card, action, source, progress): + if self._inner is None: + from windup_ai_engine.strategy.concrete import RenderFrameStrategy + from windup_framework.providers.render3d import LocalSpriteRenderProvider + + self._inner = RenderFrameStrategy(LocalSpriteRenderProvider()) + return self._inner.derive(card, action, source, progress) - return RenderFrameStrategy(LocalSpriteRenderProvider()) + return _LazyRenderStrategy() def _download_model3d(self, url: str) -> bytes: """取该造型的绑骨 3D 模型。走 ``fetch_own_media`` —— 与母版同一条受限通路 From 9b509f9cfab91d9dba6ae889bca28879d17c3751 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Fri, 14 Aug 2026 17:37:26 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test(render3d):=20=E6=A1=A9=E9=9A=8F=20=5Fd?= =?UTF-8?q?ispatch=5Fafter=5Fcommit=20=E6=96=B0=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_render3d_route_and_assets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index 28b25e29..6e5ec503 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -275,7 +275,9 @@ def test_web_layer_reads_the_outfit_model_url_into_the_task_input(auth_client, m captured: list[CharacterActionInput] = [] monkeypatch.setattr( gen_api, "_dispatch_after_commit", - lambda session, target, task_id, input_data, project_id: captured.append(input_data), + # 位置参数随 _dispatch_after_commit 的签名走;这里只关心 input_data, + # 用 *args 收下其余,免得签名一变桩就报 TypeError。 + lambda *args: captured.append(next(a for a in args if isinstance(a, CharacterActionInput))), ) resp = auth_client.post("/generation/action", json={ "project_id": project["id"], "character_id": character["id"], @@ -308,7 +310,9 @@ def test_web_layer_does_not_guess_an_outfit_when_none_is_given(auth_client, monk captured: list[CharacterActionInput] = [] monkeypatch.setattr( gen_api, "_dispatch_after_commit", - lambda session, target, task_id, input_data, project_id: captured.append(input_data), + # 位置参数随 _dispatch_after_commit 的签名走;这里只关心 input_data, + # 用 *args 收下其余,免得签名一变桩就报 TypeError。 + lambda *args: captured.append(next(a for a in args if isinstance(a, CharacterActionInput))), ) auth_client.post("/generation/action", json={ "project_id": project["id"], "character_id": character["id"], From bbdf5c531ba8fb0671006704f9e1688f7e45fb5b Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 17 Aug 2026 14:51:44 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(ai=5Fengine):=20=E6=AF=8D=E7=89=88?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E5=A2=9E=E5=8A=A0=E5=9B=9B=E8=82=A2=E7=B2=98?= =?UTF-8?q?=E8=BF=9E=E4=B8=8E=E7=8B=AC=E7=AB=8B=E8=89=B2=E5=9D=97=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/windup_ai_engine/_subject.py | 22 +- .../src/windup_ai_engine/master_check.py | 185 ++++++++++++++++- .../src/windup_ai_engine/ports/__init__.py | 20 ++ .../tests/test_master_check_shape_warnings.py | 188 ++++++++++++++++++ 4 files changed, 398 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_master_check_shape_warnings.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_subject.py b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py index bf83ab29..ee6c0852 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/_subject.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py @@ -15,7 +15,7 @@ import numpy as np from PIL import Image -__all__ = ["bg_color", "subject_bbox", "subject_mask"] +__all__ = ["bbox_of", "bg_color", "subject_bbox", "subject_mask"] ALPHA_THR = 128 # alpha 高于此值算不透明(与 postprocess.pack 求包围盒的口径一致) BG_TOL = 60 # 与背景色的 RGB 绝对差之和,超过才算主体 @@ -52,6 +52,19 @@ def subject_mask( return np.abs(rgb - _bg_median(rgb)).sum(axis=2) > bg_tol +def bbox_of(mask: np.ndarray) -> tuple[tuple[int, int, int, int], int] | None: + """已有掩码时的包围盒 + 像素数。 + + 单独拆出来是为了让**同时要掩码和包围盒**的调用方(母版预检要在掩码上再数横向连通段) + 只算一次掩码;若让它自己从掩码求包围盒,那份口径就与本模块分叉了。 + """ + ys, xs = np.where(mask) + if not len(ys): + return None + box = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + return box, int(mask.sum()) + + def subject_bbox( img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL ) -> tuple[tuple[int, int, int, int], int] | None: @@ -60,9 +73,4 @@ def subject_bbox( 包围盒与像素数一起返回:两者判的不是同一件事 —— 包围盒管"主体有多大", 像素数管"包围盒里是不是真有东西"(散落的几粒噪点能把包围盒撑满整幅)。 """ - m = subject_mask(img, alpha_thr, bg_tol) - ys, xs = np.where(m) - if not len(ys): - return None - box = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) - return box, int(m.sum()) + return bbox_of(subject_mask(img, alpha_thr, bg_tol)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py index 8b3376ea..d8c739d0 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -5,12 +5,32 @@ 画板前作画"的图请求 walk,全程无一处报错,最终产出 16 帧构图完整的序列帧,画面是个 不会走路的错角色 —— 钱已花完才发现。 -**本层判什么(三条,全部本地零成本、可复现):** +**本层判什么(全部本地零成本、可复现)** + +*拒绝(:class:`MasterRejected`,量到就是事实):* ① 能否解码 —— 坏 bytes / 截断文件不必等 i2v 跑完再发现; ② 有没有可动的主体 —— 全透明 / 全同色 = 画面里没有东西可动; ③ 主体宽高比下游装不装得下 —— 见 :data:`REJECT_ASPECT`。 +*警告(:class:`MasterWarning`,近似判据,合法母版也会命中):* + ④ 下半身横切的连通段数 —— 见 :data:`LIMB_BANDS`; + ⑤ 主体之外还有没有独立色块 —— 见 :data:`MIN_EXTRA_COMPONENT_RATIO`。 + +拒绝与警告的分界**由判据能不能证伪决定,不由后果严重程度决定**。④⑤ 指向的是混元图生 3D +的硬约束(四肢粘连 → 绑骨绑不出腿;画面里有武器配件 → 明确不允许),后果比 ③ 更贵, +但两条判据都会在合法母版上误报,所以只能警告。**上层拿它们做什么决定**:摆在母版确认闸 +上给人看,让人在花 ¥2.40 建 3D 之前决定"就用这张 / 重新生成三张";任何一条都不阻断流程。 + **本层不判什么、为什么 —— 别把下面这些当成已经守住了:** + - **画面里有没有文字**(提示词含 "reference sheet" 时生图模型会自己糊上标注,烤进母版 + 就带到每一帧)。**这条明确不做。** 纯 numpy 能做的文字信号(局部高频、笔画宽度一致、 + 小连通块成行排列)在像素画角色身上恒亮:铆钉、扣子、牙齿、瞳孔高光全是"小而密的 + 高对比块"。做出来的东西假阳性远多于真阳性,而假阳性会让人把一张好母版扔掉重生成 —— + 比漏报更贵。要真做,得上 OCR / 检测模型,那是一次按次计费的调用,与"零成本预检" + 不是一件事,得单独立项与实测。 + - **和身体焊在一起的手持物**。⑤ 只逮得到与主体**不相连**的色块;剑握在手里、与手臂 + 连成一片时它一个信号都没有 —— 与 ``providers.render3d.checks`` 那层"融进同一块网格的 + 武器逮不到"是同一个盲区的上下两端。 - **画的是不是一个角色、是不是该动作要的姿态**(walk 要侧向、attack 要蓄力,见 :data:`master_prep.MASTER_POSES`)。需要视觉模型读画面语义,本层只有 numpy。 **开头那张"人物在画板前作画"的图,本预检拦不住**:它能解码、有主体、比例正常。 @@ -29,16 +49,24 @@ from __future__ import annotations import io -from dataclasses import dataclass +from dataclasses import dataclass, field +import numpy as np from PIL import Image, UnidentifiedImageError -from windup_ai_engine._subject import subject_bbox -from windup_ai_engine.ports import MasterRejectCode, MasterRejected +from windup_ai_engine._subject import bbox_of, subject_mask +from windup_ai_engine.ports import ( + MasterRejectCode, + MasterRejected, + MasterWarning, + MasterWarningCode, +) from windup_ai_engine.postprocess.pack import FILL_H, FILL_W -__all__ = ["MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", - "MasterFacts", "check_master", "reject_aspect_for"] +__all__ = ["LIMB_BANDS", "MIN_EXTRA_COMPONENT_RATIO", "MIN_LIMB_RUN_PX", + "MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", + "MasterFacts", "check_master", "component_sizes", "limb_segments", + "main_component", "reject_aspect_for"] # 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标 # (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度 @@ -79,6 +107,101 @@ def reject_aspect_for(canvas: tuple[int, int] | None) -> float: # 占比只有百万分之几。千分之一对真角色是极宽松的下限(侧视角色通常占百分之几以上)。 MIN_SUBJECT_AREA_RATIO = 0.001 +# 在主体高度的这几处横切,数一行里有几段主体像素。取值全部落在**下半身**: +# 0.70 大腿、0.80 膝、0.88 小腿、0.94 踝。双足人形在这几处应稳定量到 2 段; +# 掉到 1 段意味着两腿之间没有空隙 —— 混元的绑骨要靠这道空隙分出左右腿。 +# +# 为什么是四处而不是一处:单处会被一次偶然的遮挡(裙摆下摆、披风、站姿交叉)否掉, +# 而"四处全都只有 1 段"才是形态问题。判据取 ``max(段数) >= 2`` 通过。 +LIMB_BANDS = (0.70, 0.80, 0.88, 0.94) + +# 一段至少这么宽才算数。描边、抗锯齿会在腿外侧留下 1–2px 的孤立像素, +# 不滤掉的话一条腿会被数成三段,"分离"反而被误判成"更分离"。 +MIN_LIMB_RUN_PX = 3 + +# 独立色块要达到最大块的这个比例才当成"另一个东西"。抗锯齿碎片、发梢、飞溅特效 +# 都会产生小连通块,阈值太低会让几乎每张母版都报警,而报警一多就没人看了。 +MIN_EXTRA_COMPONENT_RATIO = 0.02 + + +def _runs(row: np.ndarray, min_px: int = 1) -> list[tuple[int, int]]: + """一行里的连通段 ``[(x0, x1), ...]``(半开),短于 ``min_px`` 的丢掉。""" + edges = np.flatnonzero(np.diff(np.concatenate(([0], row.astype(np.int8), [0])))) + return [(int(a), int(b)) for a, b in zip(edges[::2], edges[1::2], strict=True) + if b - a >= min_px] + + +def limb_segments(mask: np.ndarray, box: tuple[int, int, int, int]) -> tuple[int, ...]: + """:data:`LIMB_BANDS` 各处的横向连通段数,与 ``LIMB_BANDS`` 一一对应。""" + x0, y0, x1, y1 = box + span = y1 - y0 - 1 + return tuple( + len(_runs(mask[min(y1 - 1, y0 + int(round(frac * span))), x0:x1], MIN_LIMB_RUN_PX)) + for frac in LIMB_BANDS + ) + + +def _label(mask: np.ndarray) -> tuple[list[tuple[int, int, int, int]], dict[int, int]]: + """连通块标注:``[(y, x0, x1, root), ...]`` + 各 root 的像素总数。**八邻接**。 + + 按行的连通段做并查集而不是逐像素扫:段数比像素数小三四个数量级,一张 1024² 的母版 + 只有几千段。八邻接是刻意的 —— 四邻接会把抗锯齿造成的对角细颈判成断开,于是同一条 + 手臂被数成两块,凭空多出一个"独立色块"警告。 + """ + parent: list[int] = [] + + def find(i: int) -> int: + root = i + while parent[root] != root: + root = parent[root] + while parent[i] != root: + parent[i], i = root, parent[i] + return root + + labelled: list[tuple[int, int, int, int]] = [] + previous: list[tuple[int, int, int]] = [] + for y in range(mask.shape[0]): + current: list[tuple[int, int, int]] = [] + for a, b in _runs(mask[y]): + label = len(parent) + parent.append(label) + for pa, pb, plabel in previous: + if a <= pb and pa <= b: # 端点相碰即视为连通 = 八邻接 + ra, rb = find(label), find(plabel) + if ra != rb: + parent[rb] = ra + labelled.append((y, a, b, label)) + current.append((a, b, label)) + previous = current + + resolved = [(y, a, b, find(label)) for y, a, b, label in labelled] + totals: dict[int, int] = {} + for _, a, b, root in resolved: + totals[root] = totals.get(root, 0) + (b - a) + return resolved, totals + + +def component_sizes(mask: np.ndarray) -> tuple[int, ...]: + """各连通块的像素数,从大到小。""" + return tuple(sorted(_label(mask)[1].values(), reverse=True)) + + +def main_component(mask: np.ndarray) -> np.ndarray: + """只保留最大连通块的掩码 —— 数腿之前必须先把画面里的别的东西剔掉。 + + 不剔的后果是两条警告互相架空:一把浮在腿侧的剑会在腿所在的那几行多贡献一段, + 于是"两腿粘连"被凑够 2 段、警告消失 —— 母版越糟糕反而越安静。 + """ + runs, totals = _label(mask) + if not totals: + return mask + root = max(totals.items(), key=lambda kv: (kv[1], -kv[0]))[0] + out = np.zeros_like(mask) + for y, a, b, r in runs: + if r == root: + out[y, a:b] = True + return out + @dataclass(frozen=True) class MasterFacts: @@ -89,13 +212,18 @@ class MasterFacts: subject_box: tuple[int, int, int, int] # 主体包围盒 (x0, y0, x1, y1),半开 subject_ratio: float # 主体 w/h subject_area_ratio: float # 主体像素 / 画幅像素 + limb_segments: tuple[int, ...] = () # LIMB_BANDS 各处的横向连通段数 + components: tuple[int, ...] = () # 够大的连通块像素数,从大到小 + warnings: tuple[MasterWarning, ...] = field(default_factory=tuple) def note(self) -> str: """给 ProgressPort 的一行摘要(会经 server 变成用户看到的进度文案)。""" w, h = self.size x0, y0, x1, y1 = self.subject_box + tail = f";{len(self.warnings)} 条警告" if self.warnings else "" return (f"母版 {w}×{h},主体 {x1 - x0}×{y1 - y0}" - f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})") + f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})" + f"{tail}") def _decode(master: bytes) -> Image.Image: @@ -116,19 +244,48 @@ def _decode(master: bytes) -> Image.Image: ) from exc +def _warnings( + segments: tuple[int, ...], blocks: tuple[int, ...] +) -> tuple[MasterWarning, ...]: + """把量到的两组数翻成警告。**只在信号明确时出声** —— 详见各条的假阳性来源。""" + out: list[MasterWarning] = [] + if segments and max(segments) < 2: + out.append(MasterWarning( + MasterWarningCode.LIMBS_FUSED, + f"下半身 {list(LIMB_BANDS)} 四处横切都只有 {list(segments)} 段主体像素," + "两腿之间量不到空隙。混元靠这道空隙分左右腿,粘连时会绑出一条腿的骨架," + "而接口不会报错。**侧视角色两腿前后重叠时本条必然误报**," + "确认这张是侧视就忽略它。", + )) + if len(blocks) > 1: + extra = ", ".join(f"{n}px" for n in blocks[1:]) + out.append(MasterWarning( + MasterWarningCode.EXTRA_COMPONENT, + f"主体({blocks[0]}px)之外还有 {len(blocks) - 1} 块独立色块({extra})。" + "混元明写送检模型不得含人体以外的组件,画面里的武器/道具会被一起建进网格、" + "再被绑上权重乱甩。也可能是生图模型自己糊上的标注文字。" + "**与身体相连的手持物本条逮不到**,只能靠人看。", + )) + return tuple(out) + + def check_master(master: bytes, canvas: tuple[int, int] | None = None) -> MasterFacts: - """母版可生成性预检。通过返回量到的形态,不通过抛 :class:`MasterRejected`。 + """母版可生成性预检。通过返回量到的形态(含警告),不通过抛 :class:`MasterRejected`。 - 只看母版本身,不看 ``ActionSpec``:三条判据都是"下游画布装不装得下 / 有没有东西可 + 只看母版本身,不看 ``ActionSpec``:拒绝那三条都是"下游画布装不装得下 / 有没有东西可 动",与动作类型无关。动作相关的母版要求(侧向 / 蓄力姿态)本层判不了,见模块 docstring。 + 警告**不影响返回**:调用方拿到 facts 就是通过了,``facts.warnings`` 要不要理是它的事。 + 这样定是因为两条警告判据都会在合法母版上误报,让它们阻断流程等于把误报变成挡路。 + ``canvas``:交付画布 ``(宽, 高)``。只影响比例上限 —— 见 :func:`reject_aspect_for`。 不给即按方形判(与加这个入参之前完全一致)。**必须与出帧用的是同一个 canvas**, 否则就成了"预检按一套几何判、出帧按另一套出"。 """ img = _decode(master) w, h = img.size - found = subject_bbox(img) + mask = subject_mask(img) + found = bbox_of(mask) if found is None: raise MasterRejected( MasterRejectCode.NO_SUBJECT, @@ -136,11 +293,19 @@ def check_master(master: bytes, canvas: tuple[int, int] | None = None) -> Master ) box, pixels = found bw, bh = box[2] - box[0], box[3] - box[1] + body = main_component(mask) + body_box = bbox_of(body) + segments = limb_segments(body, body_box[0]) if body_box else () + blocks = component_sizes(mask) + kept = tuple(n for n in blocks if n >= blocks[0] * MIN_EXTRA_COMPONENT_RATIO) facts = MasterFacts( size=(w, h), subject_box=box, subject_ratio=bw / bh, subject_area_ratio=pixels / max(1, w * h), + limb_segments=segments, + components=kept, + warnings=_warnings(segments, kept), ) if min(bw, bh) < MIN_SUBJECT_SIDE: raise MasterRejected( 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 593cbcf1..147af5ae 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 @@ -37,6 +37,26 @@ class MasterRejectCode(str, Enum): ASPECT_TOO_WIDE = "aspect_too_wide" # 主体太扁,方形 cell 里只能压成一条 +class MasterWarningCode(str, Enum): + """母版**可疑但不足以拒**的形态 —— 判据本身是近似的,误拒的代价高于漏放。 + + 与 :class:`MasterRejectCode` 的分工由**判据能不能证伪**决定,不由后果严重程度决定: + 拒绝码那几条量到就是事实(解不开、没主体、比例超限);警告码这几条量到的是相关信号, + 合法母版也可能命中(见 :mod:`windup_ai_engine.master_check` 各条的假阳性来源)。 + """ + + LIMBS_FUSED = "limbs_fused" # 下半身横切只有一段:双腿可能粘连 + EXTRA_COMPONENT = "extra_component" # 主体之外还有独立色块:可能是手持物/道具/标注 + + +@dataclass(frozen=True) +class MasterWarning: + """一条警告。``code`` 给上层做分支,``detail`` 给人看。""" + + code: MasterWarningCode + detail: str + + class MasterRejected(ValueError): """母版不具备可生成性,在**调用付费模型之前**拒绝。 diff --git a/backend/tests/test_master_check_shape_warnings.py b/backend/tests/test_master_check_shape_warnings.py new file mode 100644 index 00000000..8b32bcba --- /dev/null +++ b/backend/tests/test_master_check_shape_warnings.py @@ -0,0 +1,188 @@ +"""母版预检里**只警告不拒绝**的两条形态判据。 + +它们对着的是混元图生 3D 的硬约束(四肢要分得开、画面里不得有人体以外的组件), +而这两条约束违反了**不会报错,只会默默产出错结果** —— 与 ``providers.render3d.checks`` +挡绑骨入口是同一个动作。区别在于:那一层量到就是事实,这一层量到的只是相关信号, +所以只能摆在母版确认闸上给人看,不能拿来挡路。 + +合成样本而不是真母版:真母版拿不到"只有腿粘连、其余一模一样"的对照,而这里要证明的 +恰恰是**判据分得开这一对**。 +""" +from __future__ import annotations + +import io + +import numpy as np +import pytest +from PIL import Image + +from windup_ai_engine.master_check import ( + LIMB_BANDS, + MIN_EXTRA_COMPONENT_RATIO, + MIN_LIMB_RUN_PX, + check_master, + component_sizes, + limb_segments, +) +from windup_ai_engine.ports import MasterWarningCode + +INK = (40, 40, 60, 255) + + +def _figure(*, legs_apart: bool, prop: tuple[int, int, int, int] | None = None) -> bytes: + """一个正面站立的火柴人。``legs_apart=False`` 时两腿之间的空隙被填死。 + + 除了那道空隙(和可选的道具),两张图逐像素相同 —— 判据要是分不开这一对, + 它量到的就不是"腿分没分开"。 + """ + img = Image.new("RGBA", (200, 400), (0, 0, 0, 0)) + draw = img.load() + + def block(x0: int, y0: int, x1: int, y1: int) -> None: + for y in range(y0, y1): + for x in range(x0, x1): + draw[x, y] = INK + + block(80, 40, 120, 100) # 头 + block(70, 100, 130, 240) # 躯干 + block(40, 110, 70, 130) # 左臂 + block(130, 110, 160, 130) # 右臂 + block(74, 240, 94, 380) # 左腿 + block(106, 240, 126, 380) # 右腿 + if not legs_apart: + block(94, 240, 106, 380) # 把两腿之间的空隙填死 + if prop is not None: + block(*prop) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +def _codes(facts) -> set[MasterWarningCode]: + return {w.code for w in facts.warnings} + + +# ── ① 四肢分离 ────────────────────────────────────────────────────────────── + + +def test_legs_apart_measures_two_segments_at_every_band(): + """双腿分离的人形:四处横切**每一处**都该量到 2 段。 + + 只断言"没警告"是不够的 —— 判据恒返回 2 时也没警告。要把量到的数按住。 + """ + facts = check_master(_figure(legs_apart=True)) + assert facts.limb_segments == (2, 2, 2, 2) + assert MasterWarningCode.LIMBS_FUSED not in _codes(facts) + + +def test_fused_legs_drop_to_one_segment_and_warn(): + """空隙被填死后段数掉到 1,且必须出警告 —— 这正是绑骨分不出左右腿的形态。""" + facts = check_master(_figure(legs_apart=False)) + assert facts.limb_segments == (1, 1, 1, 1) + assert MasterWarningCode.LIMBS_FUSED in _codes(facts) + + +def test_fused_legs_are_warned_not_rejected(): + """**不能拒**:侧视角色两腿前后重叠时同样只有 1 段,而侧视正是本项目的主打。 + 拒了就是拿一条必然误报的判据挡住用户的钱。""" + facts = check_master(_figure(legs_apart=False)) + assert facts.subject_box # 走完了全程、返回了 facts + assert facts.warnings # 只是带着警告回来 + + +def test_one_occluded_band_does_not_trigger_the_warning(): + """裙摆/披风只遮住一处时不该报警 —— 判据取四处的最大值,不是每处都要 2 段。""" + img = Image.open(io.BytesIO(_figure(legs_apart=True))).convert("RGBA") + y = 40 + int(round(LIMB_BANDS[0] * (380 - 40 - 1))) # 主体 y 跨 40..380 + for dy in range(-3, 4): # 只糊掉第一条带所在的几行 + for x in range(94, 106): + img.putpixel((x, y + dy), INK) + buf = io.BytesIO() + img.save(buf, "PNG") + facts = check_master(buf.getvalue()) + assert facts.limb_segments[0] == 1 and max(facts.limb_segments) == 2 + assert MasterWarningCode.LIMBS_FUSED not in _codes(facts) + + +def test_antialiasing_speckles_do_not_inflate_the_segment_count(): + """腿外侧的 1px 孤立像素不算一段。不滤掉的话一条腿会被数成三段, + "粘连"反而被读成"更分离",判据方向整个反过来。""" + mask = np.zeros((10, 40), dtype=bool) + mask[:, 4] = True # 1px 宽的毛刺 + mask[:, 10:20] = True # 真正的一段 + assert limb_segments(mask, (0, 0, 40, 10)) == (1, 1, 1, 1) + assert MIN_LIMB_RUN_PX > 1 + + +# ── ② 主体之外的独立色块 ───────────────────────────────────────────────────── + + +def test_lone_figure_is_a_single_component(): + facts = check_master(_figure(legs_apart=True)) + assert len(facts.components) == 1 + assert MasterWarningCode.EXTRA_COMPONENT not in _codes(facts) + + +def test_detached_prop_is_reported_as_an_extra_component(): + """画面里多一把不挨着身体的剑 → 必须报出来:它会被一起建进网格再绑上权重。""" + facts = check_master(_figure(legs_apart=True, prop=(170, 150, 180, 300))) + assert len(facts.components) == 2 + assert MasterWarningCode.EXTRA_COMPONENT in _codes(facts) + + +def test_held_prop_touching_the_body_is_invisible_to_this_check(): + """**已知盲区,写成用例免得有人当它守住了**:道具与手臂相连时并成一块,零信号。""" + facts = check_master(_figure(legs_apart=True, prop=(160, 110, 175, 300))) + assert len(facts.components) == 1 + assert MasterWarningCode.EXTRA_COMPONENT not in _codes(facts) + + +def test_tiny_fragments_are_below_the_reporting_threshold(): + """抗锯齿碎片不该报警 —— 报警一多就没人看了。""" + facts = check_master(_figure(legs_apart=True, prop=(180, 20, 183, 23))) + assert len(facts.components) == 1 + assert MasterWarningCode.EXTRA_COMPONENT not in _codes(facts) + assert 0 < MIN_EXTRA_COMPONENT_RATIO < 1 + + +def test_components_are_eight_connected(): + """四邻接会把抗锯齿造成的对角细颈判成断开,同一条手臂被数成两块。""" + mask = np.zeros((4, 4), dtype=bool) + mask[0, 0] = mask[1, 1] = mask[2, 2] = True + assert component_sizes(mask) == (3,) + + +def test_component_sizes_are_ordered_largest_first(): + mask = np.zeros((6, 20), dtype=bool) + mask[1, 1:3] = True + mask[1, 10:16] = True + assert component_sizes(mask) == (6, 2) + + +# ── ③ 与既有拒绝码的分工 ───────────────────────────────────────────────────── + + +def test_a_detached_prop_cannot_silence_the_fused_legs_warning(): + """**两条判据不得互相架空。** 一把浮在腿侧的剑会在腿所在的那几行多贡献一段; + 若在整幅掩码上数腿,粘连的两腿就被凑够 2 段、警告消失 —— 母版越糟糕反而越安静。 + 所以数腿只在最大连通块上做。""" + facts = check_master(_figure(legs_apart=False, prop=(170, 240, 180, 380))) + assert facts.limb_segments == (1, 1, 1, 1) + assert _codes(facts) == { + MasterWarningCode.LIMBS_FUSED, + MasterWarningCode.EXTRA_COMPONENT, + } + assert all(w.detail for w in facts.warnings) + + +def test_note_mentions_warnings_so_progress_text_is_not_silently_clean(): + clean = check_master(_figure(legs_apart=True)).note() + warned = check_master(_figure(legs_apart=False)).note() + assert "警告" not in clean + assert "警告" in warned + + +@pytest.mark.parametrize("frac", LIMB_BANDS) +def test_bands_all_sit_in_the_lower_body(frac: float): + """带位必须都在下半身。挪到躯干上去的话,量到的是"腰有没有断开",与腿无关。""" + assert 0.5 < frac < 1.0 From 991d557c07d30aef8c74460758310f0750e08616 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 17 Aug 2026 14:51:44 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(server):=20=E9=80=A0=E5=9E=8B=E7=BA=A7?= =?UTF-8?q?=203D=20=E8=B5=84=E4=BA=A7=E7=9A=84=E5=BB=BA=E9=80=A0=E3=80=81?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E4=B8=8E=E4=B8=A2=E5=BC=83=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/windup_app/bootstrap/app.py | 7 + .../server/orchestrator/render3d_assets.py | 110 ++++- .../server/orchestrator/render3d_service.py | 392 ++++++++++++++++++ .../app/src/windup_app/web/api/character.py | 8 +- .../app/src/windup_app/web/api/render3d.py | 196 +++++++++ .../tests/test_render3d_asset_endpoints.py | 355 ++++++++++++++++ openapi.json | 355 ++++++++++++++++ 7 files changed, 1412 insertions(+), 11 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py create mode 100644 backend/packages/app/src/windup_app/web/api/render3d.py create mode 100644 backend/tests/test_render3d_asset_endpoints.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index fcfa903a..f1d9cb4c 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -28,10 +28,12 @@ from windup_app.web.api.character import router as character_router from windup_app.server.orchestrator import task_repo from windup_app.server.orchestrator.executor import run_action_task, run_image_task +from windup_app.server.orchestrator.render3d_service import default_operations, precheck_master from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router from windup_app.web.api.project import router as project_router from windup_app.web.api.quota import router as quota_router +from windup_app.web.api.render3d import router as render3d_router from windup_app.web.api.workflow_run import router as workflow_run_router from windup_app.web.handler.exception_handlers import register_exception_handlers from windup_app.web.middleware.auth import AuthMiddleware @@ -111,11 +113,16 @@ def health() -> dict[str, str]: app.include_router(media_router) app.include_router(generation_router) app.include_router(quota_router) + app.include_router(render3d_router) # 生成任务的后台执行器挂到 app.state:端点只建 PENDING 记录立即返回,真正的 # 图生图/i2v 在后台线程跑。放在 state 而不是 import 到 web 层,是因为 # import-linter 的分层契约禁止 app.web 直连 ai_engine,而 executor 要调它。 app.state.run_action_task = run_action_task app.state.run_image_task = run_image_task + # 母版预检与建 3D 资产同理:两者都经 ai_engine,web 层不能静态依赖。 + # 预检是零成本纯函数;建资产要花钱,``default_operations`` 自带 WINDUP_RENDER3D_ALLOW_SPEND 开关。 + app.state.precheck_master = precheck_master + app.state.render3d_operations = default_operations() # task_repo 状态变更时自动推 SSE。延迟 import 避免与 generation 模块循环依赖。 from windup_app.web.api.generation import event_bus diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py index 3066a85f..f2fafa94 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py @@ -39,9 +39,15 @@ import hashlib import logging import pathlib +from enum import Enum from typing import TYPE_CHECKING, Protocol, runtime_checkable from windup_ai_engine.ports import ProgressPort +from windup_framework.providers.render3d.tencent import ( + CREDIT_PRICE_CNY, + CREDITS, + RIG_CREDITS, +) if TYPE_CHECKING: from windup_framework.providers.render3d import ( @@ -52,18 +58,47 @@ logger = logging.getLogger(__name__) +# ① 出了模型但还没绑骨的产物,存在同一个 store 里的这个键前缀下。**别在别处再写一遍 +# 字面量** —— 待审模型"在哪"这件事有两个说法时,放行与展示会指向不同的文件。 +RAW_KEY_PREFIX = "raw:" + +# 两段的报价。**不在这里抄数字**,从计费实现取 —— 抄一份过去,供应商调价时两处会分叉, +# 而分叉的那一份正是给用户看的成本提示(告知了错的价钱比不告知更糟)。 +# ``CREDITS["Normal"]`` 是本管线用的生成模式(非 PBR、单视图),与 ``TencentModel3DProvider`` +# 的默认档一致。 +MODEL3D_CREDITS = CREDITS["Normal"] +AUTORIG_CREDITS = RIG_CREDITS +BUILD_CREDITS = MODEL3D_CREDITS + AUTORIG_CREDITS +BUILD_CNY = round(BUILD_CREDITS * CREDIT_PRICE_CNY, 2) + + +class Render3DAssetState(str, Enum): + """一个造型的 3D 资产处在哪一步。**状态由落点推出来,不单独存一份** —— + 存第二份就有第二个真相,而这两者不同步时用户看到的是"已就绪"、渲帧拿到的是空。 + """ + + ABSENT = "absent" # 什么都没有,点"建"会开始花钱 + AWAITING_REVIEW = "awaiting_review" # ① 已出模型,卡在人工确认闸上 + READY = "ready" # ② 已绑骨,渲帧可直接用 + + @runtime_checkable class CharacterAssetStore(Protocol): """角色级派生资产(绑好骨的 3D 模型)的落点。 - 只有两个动作,且**必须是跨进程持久的** —— 进程内缓存等于每次重启都重付一遍 - ①②,而那正是本文件开头那笔一个数量级的差价。 + **必须是跨进程持久的** —— 进程内缓存等于每次重启都重付一遍 ①②,而那正是本文件 + 开头那笔一个数量级的差价。 """ def get(self, key: str) -> bytes | None: ... def put(self, key: str, data: bytes) -> None: ... + def delete(self, key: str) -> None: + """删掉一份产物。给"模型不合格、重新生成"用 —— 不删的话下次调用会把同一个坏 + 模型再交一遍给人审,重生成的入口就成了死键。""" + ... + class LocalDirAssetStore(CharacterAssetStore): """落在本地目录的实现。 @@ -94,6 +129,18 @@ def put(self, key: str, data: bytes) -> None: tmp.write_bytes(data) tmp.replace(p) + def delete(self, key: str) -> None: + self._path(key).unlink(missing_ok=True) + + +class SpendNotAuthorized(ValueError): + """要花钱建资产,但本部署没打开花钱开关。 + + 单拎一个类型是给上层用的:它与"造型 id 缺失""母版拉不到"这些同样抛 ValueError 的 + 输入问题修法完全不同(前者改部署配置,后者改请求),压成一种就只能靠比对消息文本 + 分支 —— 而消息会改。继承 ValueError 让既有的 ``pytest.raises(ValueError)`` 仍然成立。 + """ + class ModelAwaitingReview(RuntimeError): """3D 模型已生成、**在等人看过点头**,还不能往下走。 @@ -129,6 +176,16 @@ def is_approved(self, key: str) -> bool: """人是否已点头。**不得自动变 True** —— 那就等于这道闸不存在。""" ... + def approve(self, key: str) -> None: + """人看过并点头。**只允许由人的显式操作触达**(CLI、或前端那个"通过"按钮), + 管线自身任何一条路径都不得调它 —— 会自己点头的闸就是没有闸。""" + ... + + def discard(self, key: str) -> None: + """人看过并否掉:丢弃待审模型。混元的模型改不动,不合格只能重生成, + 所以否掉必须真的把它删了 —— 留着的话下次调用会把同一个坏模型再交一遍。""" + ... + class LocalDirModelReview(ModelReviewGate): """落本地目录 + 一个批准标记文件。 @@ -157,9 +214,16 @@ def is_approved(self, key: str) -> bool: return self._stem(key).with_suffix(".approved").is_file() def approve(self, key: str) -> None: - """人看过之后放行(给 CLI / 运维用;管线自己**不会**调这个)。""" + """人看过之后放行(给 CLI / 运维 / 前端那个"通过"按钮用;管线自己**不会**调这个)。""" self._stem(key).with_suffix(".approved").write_text("ok", encoding="utf-8") + def discard(self, key: str) -> None: + """否掉待审模型。连批准标记一起删:留着标记而删了模型,下次生成出来的新模型 + 会被这枚旧标记直接放行,人一眼都没看到就进了绑骨。""" + stem = self._stem(key) + for path in self._root.glob(f"{stem.name}.*"): + path.unlink(missing_ok=True) + class Render3DAssetBuilder: """把①图生 3D + ②自动绑骨拼成"母版 → 该造型的绑骨模型",并落点复用。 @@ -182,6 +246,12 @@ def __init__( self._review = review self._may_build_assets = may_build_assets + @property + def may_build_assets(self) -> bool: + """本实例获准花钱建资产没有。给上层**在起后台任务之前**问 —— 起了再失败的话, + 用户看到的是"建到一半炸了",而事实是这台机器根本没打算建。""" + return self._may_build_assets + def get(self, outfit_key: str) -> bytes | None: """已就绪的绑骨模型;``None`` = 还没有。**不花钱、无副作用。** @@ -190,6 +260,31 @@ def get(self, outfit_key: str) -> bytes | None: """ return self._store.get(outfit_key) if outfit_key else None + def state(self, outfit_key: str) -> Render3DAssetState: + """该造型走到哪一步了。**不花钱、无副作用**,给状态查询端点用。""" + if outfit_key and self._store.get(outfit_key) is not None: + return Render3DAssetState.READY + if outfit_key and self._store.get(f"{RAW_KEY_PREFIX}{outfit_key}") is not None: + return Render3DAssetState.AWAITING_REVIEW + return Render3DAssetState.ABSENT + + def approve(self, outfit_key: str) -> None: + """人点头放行。**本类不会自己调它** —— 调用点只有面向人的入口(端点 / CLI)。 + + 放行本身不绑骨:绑骨是下一次 :meth:`ensure` 的事,那里才有母版和进度回调。 + """ + self._review.approve(outfit_key) + + def discard(self, outfit_key: str) -> None: + """人否掉待审模型:删待审件,回到 ``ABSENT``,下次 :meth:`ensure` 重新生成。 + + **注意这一步的代价**:重新生成要再付一次图生 3D 的 20 积分。之所以还是删, + 是因为混元的模型改不动(生成即最终),留着一个不合格的模型只有两种下场 —— + 要么被误放行进绑骨(再赔 10 积分和之后所有出帧),要么永远卡在闸上。 + """ + self._store.delete(f"{RAW_KEY_PREFIX}{outfit_key}") + self._review.discard(outfit_key) + def ensure(self, outfit_key: str, master: bytes, progress: ProgressPort) -> bytes: """取该造型的绑骨模型;没有且获准时才现建。 @@ -206,9 +301,10 @@ def ensure(self, outfit_key: str, master: bytes, progress: ProgressPort) -> byte if rigged_bytes is not None: return rigged_bytes if not self._may_build_assets: - raise ValueError( - f"造型 {outfit_key!r} 的 3D 资产未就绪,而本实例未获准建(建一次约 ¥3.60:" - "图生 3D 20 积分 + 绑骨 10 积分)。要现建请显式授权花钱," + raise SpendNotAuthorized( + f"造型 {outfit_key!r} 的 3D 资产未就绪,而本实例未获准建(建一次 " + f"{BUILD_CREDITS} 积分,约 ¥{BUILD_CNY}:图生 3D {MODEL3D_CREDITS} + " + f"绑骨 {AUTORIG_CREDITS})。要现建请显式授权花钱," "或先把资产备好,或改走 video_i2v。" ) return self._build(outfit_key, master, progress) @@ -220,7 +316,7 @@ def _build(self, key: str, master: bytes, progress: ProgressPort) -> bytes: 中间那道人工确认是硬停点,原因见 :class:`ModelReviewGate`:模型不可事后修改, 坏模型只能重生成,所以要在**花绑骨的钱之前**让人看一眼。 """ - raw_key = f"raw:{key}" + raw_key = f"{RAW_KEY_PREFIX}{key}" # 图生 3D 的产物单独存一份。**这不是冗余** —— 待审期间会有第二次、第三次调用走到 # 这里,若不存,每次都要重付一遍图生 3D 的钱,而停点的本意恰恰是省钱。 diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py new file mode 100644 index 00000000..ca20e750 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py @@ -0,0 +1,392 @@ +"""造型级 3D 资产的**面向人的**那一层:母版预检 + 建资产流程的状态与放行。 + +``render3d_assets.Render3DAssetBuilder`` 只认 bytes、只做"没有就建",它不知道母版在 +哪张表的哪个字段上、也没有可以给人点的按钮。本模块补上这两件,并把两段付费调用挪出 +请求线程 —— 图生 3D 与绑骨各要几十秒到几分钟,同步跑等于让浏览器挂在那儿等超时。 + +**分层**:本模块经 builder 牵出 ai_engine,故 web/worker **不得 import 本模块** +(会违反"入口层不经 ai_engine 直连"的门禁,该契约是传递性的)。装配方式与 +``executor`` 一样:bootstrap import + 注入 ``app.state``,web 端运行期取回。 +出参因此一律是 JSON-ready 的 ``dict`` —— 让 web 层为了标注类型去 import 本模块, +等于把刚才那条门禁绕过去。 + +━━ 为什么母版这道闸比模型那道更值钱 ━━ + +一张母版约 ¥0.29,图生 3D 一次 ¥2.40。母版不合格 → 模型必然不合格,而混元的模型 +**生成即最终**(拓扑、绑点在生成那一步定死,事后改不动),只能重来。所以同一个错误, +在母版处纠成本是 ¥0.29,在模型处纠是 ¥2.40 + 已花的 ¥0.29。闸要放在最便宜的位置。 +""" +from __future__ import annotations + +import logging +import os +import pathlib +import threading +from collections.abc import Callable +from dataclasses import dataclass + +from windup_ai_engine.master_check import check_master +from windup_ai_engine.ports import MasterRejected + +from windup_app.server.orchestrator._fetch import FetchNotAllowed, fetch_own_media +from windup_app.server.orchestrator.render3d_assets import ( + AUTORIG_CREDITS, + BUILD_CNY, + BUILD_CREDITS, + MODEL3D_CREDITS, + RAW_KEY_PREFIX, + CharacterAssetStore, + LocalDirAssetStore, + LocalDirModelReview, + ModelAwaitingReview, + Render3DAssetBuilder, + Render3DAssetState, + SpendNotAuthorized, +) + +logger = logging.getLogger("windup.render3d.service") + +__all__ = [ + "PHASE_BUILDING", "PHASE_FAILED", "PHASE_RIGGING", + "FetchNotAllowed", "MasterPrecheckFailed", "Render3DAssetOperations", + "SpendNotAuthorized", "default_operations", "precheck_master", + "precheck_master_bytes", +] + +# 落点里存"绑骨模型的公网 URL"用的键前缀。与模型 bytes 同一个 store,是因为两者的 +# 存活期必须一致:模型还在而 URL 丢了,前端会一直显示"没有 3D 模型",而钱已经花完。 +_URL_PREFIX = "url:" + +# 只存在于内存里的两个过渡态。进程重启会丢 —— 丢了也只是退回按落点推出来的真状态 +# (absent / awaiting_review),不会撒谎说"已就绪"。 +PHASE_BUILDING = "building" +PHASE_RIGGING = "rigging" +PHASE_FAILED = "failed" + + +def precheck_master_bytes(master: bytes, canvas: tuple[int, int] | None = None) -> dict: + """对一张母版跑零成本预检,把结果翻成 JSON。**不抛业务异常,拒绝也是一种结果。** + + 拒绝之所以走返回值而不是异常:第一个调用方是母版确认闸上的展示,"这张不能用" + 正是它要显示的东西。真正需要拦住的地方(建资产入口)自己看 ``accepted``, + 见 :meth:`Render3DAssetOperations.build`。 + """ + try: + facts = check_master(master, canvas) + except MasterRejected as exc: + return {"accepted": False, "reject_code": exc.code.value, "detail": exc.detail, + "facts": None, "warnings": []} + return { + "accepted": True, + "reject_code": None, + "detail": facts.note(), + "facts": { + "width": facts.size[0], + "height": facts.size[1], + "subject_ratio": round(facts.subject_ratio, 4), + "subject_area_ratio": round(facts.subject_area_ratio, 6), + "limb_segments": list(facts.limb_segments), + "components": list(facts.components), + }, + "warnings": [{"code": w.code.value, "detail": w.detail} for w in facts.warnings], + } + + +def precheck_master(master_url: str, canvas: tuple[int, int] | None = None) -> dict: + """:func:`precheck_master_bytes` 的 URL 版。取图受限于自家对象存储,见 ``_fetch``。""" + return precheck_master_bytes(fetch_own_media(master_url), canvas) + + +class MasterPrecheckFailed(ValueError): + """母版没过预检,拒绝为它花钱建 3D。``report`` 原样带给上层做文案。""" + + def __init__(self, report: dict) -> None: + super().__init__(f"母版未通过预检({report['reject_code']}):{report['detail']}") + self.report = report + + +@dataclass +class _Job: + """一个造型正在进行中的那段付费调用。``error`` 非空即 :data:`PHASE_FAILED`。""" + + phase: str + error: str | None = None + + +def _spawn_thread(work: Callable[[], None]) -> None: + threading.Thread(target=work, daemon=True).start() + + +class Render3DAssetOperations: + """建资产流程里**人能触达的四个动作**:看状态、建、放行、否掉。 + + ``spawn`` 可注入:缺省起后台线程,测试里换成"就地跑完",这样用例不必等线程收敛 + (等线程的用例会变成偶发失败,而偶发失败最后都会被人当噪音忽略)。 + """ + + def __init__( + self, + builder: Render3DAssetBuilder, + store: CharacterAssetStore, + publish: Callable[[bytes], str], + *, + fetch: Callable[[str], bytes] = fetch_own_media, + spawn: Callable[[Callable[[], None]], None] = _spawn_thread, + ) -> None: + self._builder = builder + self._store = store + self._publish = publish + self._fetch = fetch + self._spawn = spawn + self._jobs: dict[str, _Job] = {} + self._lock = threading.Lock() + + # ── 查 ─────────────────────────────────────────────────────────────── + def view(self, outfit_key: str) -> dict: + """状态 + 成本。**不花钱、无副作用**,可以随便轮询。 + + 成本恒在返回里,不只在"还没建"的时候给:前端拿它渲染那条"20+10 积分、 + 约 ¥3.60"的提示,而按次计费的触发点绝不能出现在用户不知情的时候。 + """ + state = self._builder.state(outfit_key) + with self._lock: + job = self._jobs.get(outfit_key) + phase = state.value + error = None + if job is not None and state is not Render3DAssetState.READY: + phase, error = job.phase, job.error + rigged_url = self._store.get(f"{_URL_PREFIX}{outfit_key}") + review_url = self._store.get(f"{_URL_PREFIX}{RAW_KEY_PREFIX}{outfit_key}") + return { + "asset_key": outfit_key, + "state": phase, + "model_3d_url": rigged_url.decode() if rigged_url else None, + "review_model_url": review_url.decode() if review_url else None, + "error": error, + "cost": { + "model3d_credits": MODEL3D_CREDITS, + "autorig_credits": AUTORIG_CREDITS, + "total_credits": BUILD_CREDITS, + "total_cny": BUILD_CNY, + "billing": "postpaid", + "scope": "per_outfit_once", + }, + } + + # ── 三个动作 ───────────────────────────────────────────────────────── + def build(self, outfit_key: str, master_url: str) -> dict: + """① 图生 3D。**这一步开始花钱**,所以只在两个前提都成立时才起: + 该造型确实还什么都没有,且母版过得了零成本预检。 + + 预检不过就在这里拒:母版不合格 → 模型必然不合格,而模型改不动只能重生成。 + 警告不拦 —— 它们已经在母版确认闸上给人看过,人点了"就用这张"就是他的决定。 + """ + if not self._builder.may_build_assets: + raise SpendNotAuthorized( + f"本部署未开启建 3D 资产(需 WINDUP_RENDER3D_ALLOW_SPEND)。建一次 " + f"{BUILD_CREDITS} 积分,约 ¥{BUILD_CNY}。" + ) + state = self._builder.state(outfit_key) + if state is not Render3DAssetState.ABSENT: + raise ValueError(f"造型 {outfit_key!r} 的 3D 资产已处于 {state.value},不能重复建") + with self._lock: + if outfit_key in self._jobs and self._jobs[outfit_key].phase != PHASE_FAILED: + raise ValueError(f"造型 {outfit_key!r} 的 3D 资产正在建,别重复提交") + + master = self._fetch(master_url) + report = precheck_master_bytes(master) + if not report["accepted"]: + raise MasterPrecheckFailed(report) + + self._start(outfit_key, PHASE_BUILDING, master) + return self.view(outfit_key) + + def approve(self, outfit_key: str, master_url: str) -> dict: + """人点头 → ② 绑骨。**这道闸不会自己点头**,只有本方法能放行,而它只挂在 + 面向人的端点上。""" + state = self._builder.state(outfit_key) + if state is not Render3DAssetState.AWAITING_REVIEW: + raise ValueError(f"造型 {outfit_key!r} 处于 {state.value},没有待审模型可放行") + self._builder.approve(outfit_key) + self._start(outfit_key, PHASE_RIGGING, self._fetch(master_url)) + return self.view(outfit_key) + + def discard(self, outfit_key: str) -> dict: + """人否掉待审模型 → 回到 ``absent``,下次建会重新生成(再付一次图生 3D)。""" + state = self._builder.state(outfit_key) + if state is not Render3DAssetState.AWAITING_REVIEW: + raise ValueError(f"造型 {outfit_key!r} 处于 {state.value},没有待审模型可否掉") + self._builder.discard(outfit_key) + self._store.delete(f"{_URL_PREFIX}{RAW_KEY_PREFIX}{outfit_key}") + with self._lock: + self._jobs.pop(outfit_key, None) + return self.view(outfit_key) + + # ── 内部 ───────────────────────────────────────────────────────────── + def _start(self, outfit_key: str, phase: str, master: bytes) -> None: + with self._lock: + self._jobs[outfit_key] = _Job(phase) + self._spawn(lambda: self._run(outfit_key, master)) + + def _publish_for_review(self, outfit_key: str) -> None: + """把待审模型也放到对象存储上。 + + **不放的话这道闸没法用**:模型只躺在服务器磁盘上,人点"通过"时其实一眼都没看到, + 于是闸退化成一个必须点的按钮 —— 比没有闸更糟,它制造了"已经审过"的假象。 + """ + url_key = f"{_URL_PREFIX}{RAW_KEY_PREFIX}{outfit_key}" + if self._store.get(url_key) is not None: + return + model = self._store.get(f"{RAW_KEY_PREFIX}{outfit_key}") + if model is None: + return + try: + self._store.put(url_key, self._publish(model).encode()) + except Exception: # noqa: BLE001 - 看不了不等于建失败 + logger.exception("[WINDUP] 待审模型上传失败 | outfit=%s", outfit_key) + + def _run(self, outfit_key: str, master: bytes) -> None: + """两段付费调用共用这一条:``ensure`` 自己知道该走 ① 还是 ②。 + + ``ModelAwaitingReview`` 不是失败,是 ① 干完了、停在闸上 —— 把它当失败会让 + 用户看到一条红色报错,而实际上该看到的是"去看模型"。 + """ + try: + rigged = self._builder.ensure(outfit_key, master, _SilentProgress()) + except ModelAwaitingReview: + self._publish_for_review(outfit_key) + with self._lock: + self._jobs.pop(outfit_key, None) + return + except Exception as exc: # noqa: BLE001 - 后台线程兜底 + logger.exception("[WINDUP] 建 3D 资产失败 | outfit=%s", outfit_key) + with self._lock: + self._jobs[outfit_key] = _Job(PHASE_FAILED, f"{type(exc).__name__}: {exc}") + return + try: + url = self._publish(rigged) + except Exception as exc: # noqa: BLE001 - 同上 + logger.exception("[WINDUP] 绑骨模型上传失败 | outfit=%s", outfit_key) + with self._lock: + self._jobs[outfit_key] = _Job( + PHASE_FAILED, + f"绑骨已完成(积分已扣)但上传失败,重试不会重新扣费:{exc}", + ) + return + self._store.put(f"{_URL_PREFIX}{outfit_key}", url.encode()) + with self._lock: + self._jobs.pop(outfit_key, None) + + +class _SilentProgress: + """建资产跑在后台线程上,没有 SSE 连接可推;进度落日志。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + logger.info("[WINDUP] render3d %s %d/%d %s", stage, i, total, note) + + +# ── 真实装配 ──────────────────────────────────────────────────────────────── + + +class _LazyModel3D: + """腾讯云凭证在**真要花钱的那一刻**才解析。 + + 装配期解析的后果是:没配三渲二凭证的部署整个起不来,而那些部署里绝大多数请求 + 根本不走这条路线 —— 一条没人用的路线不该有权否决整个服务的启动。 + """ + + def __init__(self, allow_spend: bool) -> None: + self._allow_spend = allow_spend + + def image_to_3d(self, master: bytes, *, want: str = "GLB") -> bytes: + from windup_framework.providers.render3d import TencentModel3DProvider + + return TencentModel3DProvider(allow_spend=self._allow_spend).image_to_3d( + master, want=want + ) + + +class _LazyAutoRig: + def __init__(self, allow_spend: bool) -> None: + self._allow_spend = allow_spend + + def rig(self, model: bytes, *, want: str = "GLB", motion=None): + from windup_framework.providers.render3d import ( + TencentAutoRigProvider, + TencentCosModelUploader, + ) + + provider = TencentAutoRigProvider( + TencentCosModelUploader(), allow_spend=self._allow_spend + ) + return provider.rig(model, want=want, motion=motion) + + +def _publish_model(data: bytes) -> str: + """把绑骨模型放到对象存储,拿到 ``outfits[].model_3d_url`` 要的那个 URL。""" + from windup_app.server.media.model import MediaUploadInput + from windup_app.server.media.service import service as media_service + + return media_service.upload( + data, + MediaUploadInput( + filename="rigged.glb", + content_type="model/gltf-binary", + size=len(data), + category="model-3d", + ), + ).url + + +def _assemble() -> Render3DAssetOperations: + """线上装配。``WINDUP_RENDER3D_ALLOW_SPEND`` 关着时端点照常在,只是点"建"会明说 + 本部署不许花钱 —— 比不装好:不装的话前端连状态都读不到,用户看到的是坏页面 + 而不是"这台机器没开这个功能"。 + + 落点目录**必须挂持久卷**,理由见 ``LocalDirAssetStore``。 + """ + root = pathlib.Path(os.getenv("WINDUP_RENDER3D_ASSET_DIR") or "var/render3d") + allow_spend = os.getenv("WINDUP_RENDER3D_ALLOW_SPEND", "").strip().lower() in { + "1", "true", "yes", "on", + } + store = LocalDirAssetStore(root / "assets") + builder = Render3DAssetBuilder( + model3d=_LazyModel3D(allow_spend), + autorig=_LazyAutoRig(allow_spend), + store=store, + review=LocalDirModelReview(root / "review"), + may_build_assets=allow_spend, + ) + return Render3DAssetOperations(builder, store, _publish_model) + + +class _LazyOperations: + """真有人调这些端点时才装配。 + + 装配会建落点目录,而 ``create_app()`` 每跑一次就装配一次 —— 包括每个测试用例。 + 在工作目录里落一堆空目录不是功能,是副作用。 + """ + + def __init__(self) -> None: + self._inner: Render3DAssetOperations | None = None + + def _ops(self) -> Render3DAssetOperations: + if self._inner is None: + self._inner = _assemble() + return self._inner + + def view(self, outfit_key: str) -> dict: + return self._ops().view(outfit_key) + + def build(self, outfit_key: str, master_url: str) -> dict: + return self._ops().build(outfit_key, master_url) + + def approve(self, outfit_key: str, master_url: str) -> dict: + return self._ops().approve(outfit_key, master_url) + + def discard(self, outfit_key: str) -> dict: + return self._ops().discard(outfit_key) + + +def default_operations() -> _LazyOperations: + return _LazyOperations() diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py index 9947c9ed..aab2720a 100644 --- a/backend/packages/app/src/windup_app/web/api/character.py +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -106,7 +106,7 @@ def _get_project_or_raise( return project -def _get_character_with_auth( +def get_character_with_auth( session: Session, character_id: int, user_id: int, ) -> Character: """获取角色并校验其所属项目属于当前用户。 @@ -186,7 +186,7 @@ def get_character( session: Session = Depends(get_session), ) -> Response[CharacterOut]: user_id = request.state.current_user.id - character = _get_character_with_auth(session, character_id, user_id) + character = get_character_with_auth(session, character_id, user_id) return Response.success(CharacterOut.model_validate(character)) @@ -198,7 +198,7 @@ def update_character( session: Session = Depends(get_session), ) -> Response[CharacterOut]: user_id = request.state.current_user.id - _get_character_with_auth(session, character_id, user_id) + get_character_with_auth(session, character_id, user_id) fields = body.model_dump(exclude_unset=True) # 如果更新了 character_data,自动推断 status if "character_data" in fields: @@ -221,7 +221,7 @@ def delete_character( session: Session = Depends(get_session), ) -> Response[None]: user_id = request.state.current_user.id - character = _get_character_with_auth(session, character_id, user_id) + character = get_character_with_auth(session, character_id, user_id) # 先提取对象 key,再删 DB 记录 object_keys = _extract_object_keys(character) diff --git a/backend/packages/app/src/windup_app/web/api/render3d.py b/backend/packages/app/src/windup_app/web/api/render3d.py new file mode 100644 index 00000000..4d06752c --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/render3d.py @@ -0,0 +1,196 @@ +"""母版预检与造型级 3D 资产的端点 —— 前端那道"确认 → 建 → 审"闸的后端一侧。 + +**本模块不 import ai_engine,也不 import 任何会牵出它的 server 模块**(门禁 +"入口层不经 ai_engine 直连"是传递性的)。两件事都经 ``request.app.state`` 上的 +运行期注入拿到,与 ``executor`` 走的是同一条路;bootstrap 是唯一的装配点。 + +代价是这里拿到的是 ``dict`` 而不是带类型的对象,响应模型只能在本文件重写一遍。 +这是刻意的:为了标注类型去 import 那边,门禁当场就红。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import Response +from windup_framework.db import get_session + +from windup_app.server.character.model import Character, CharacterData +from windup_app.server.character.service import service as character_service +from windup_app.web.api.character import get_character_with_auth + +logger = logging.getLogger("windup.render3d.api") + +router = APIRouter(prefix="/render3d", tags=["render3d"]) + + +class MasterPrecheckRequest(BaseModel): + """要预检的母版。只收自家对象存储的 URL —— 服务端替调用方拉任意地址等于把服务器 + 当跳板,见 ``orchestrator._fetch``。""" + + image_url: str = Field(..., min_length=1) + canvas_width: int | None = Field(default=None, gt=0) + canvas_height: int | None = Field(default=None, gt=0) + + +def _operations(request: Request): + """建资产的四个动作。没装配就明说,别让端点抛 AttributeError。""" + operations = getattr(request.app.state, "render3d_operations", None) + if operations is None: + raise BizException("三渲二资产服务未装配", code=BizCode.INTERNAL_ERROR) + return operations + + +def _precheck(request: Request): + precheck = getattr(request.app.state, "precheck_master", None) + if precheck is None: + raise BizException("母版预检服务未装配", code=BizCode.INTERNAL_ERROR) + return precheck + + +def _asset_key(character_id: int, outfit_id: str) -> str: + """3D 资产落点的键。**必须带上角色 id**:``outfit_id`` 只在所属角色内唯一, + 而工作流给首个造型的 id 是写死的 ``outfit-default`` —— 只用它当键,全站每个角色 + 的默认造型会共用同一个 3D 模型,表现为"别人的角色套着我的模型",且没有任何报错。 + """ + return f"character-{character_id}/{outfit_id}" + + +def _outfit_or_raise(character: Character, outfit_id: str) -> dict: + for outfit in (character.character_data or {}).get("outfits", []): + if outfit.get("id") == outfit_id: + return outfit + raise BizException("造型不存在", code=BizCode.NOT_FOUND) + + +def _master_url_or_raise(outfit: dict) -> str: + """建资产用的母版就是造型的定妆母版。 + + 没有它就不能往下走:图生 3D 的入参只有这一张图,拿角色参考图顶替会建出另一个造型 + 的模型,而接口照常成功、照常扣积分。 + """ + url = outfit.get("preview_url") + if not url: + raise BizException( + "该造型还没有已确认的定妆母版,先在工作流里确认母版再建 3D 资产", + code=BizCode.BAD_REQUEST, + ) + return url + + +def _sync_model_url(session: Session, character: Character, outfit_id: str, url: str | None) -> None: + """把建好的模型 URL 回写到 ``character_data``。 + + 回写发生在**读状态**这一步而不是后台线程里:后台线程没有请求作用域的 session, + 而三渲二那条路线的判据(``Outfit.model_3d_url``)不回写就永远是 None —— 资产建好了 + 却依旧显示"该造型暂无绑骨 3D 模型",钱白花。 + """ + if not url: + return + data = CharacterData.model_validate(character.character_data or {}) + changed = False + for outfit in data.outfits: + if outfit.id == outfit_id and outfit.model_3d_url != url: + outfit.model_3d_url = url + changed = True + if not changed: + return + character_service.update_character(session, character.id, character_data=data.model_dump()) + + +@router.post("/master-precheck", response_model=Response[dict]) +def precheck_master( + body: MasterPrecheckRequest, + request: Request, +) -> Response[dict]: + """零成本母版预检。**不产生任何按次计费调用**,可以在确认闸上随便调。""" + canvas = ( + (body.canvas_width, body.canvas_height) + if body.canvas_width and body.canvas_height + else None + ) + try: + report = _precheck(request)(body.image_url, canvas) + except ValueError as exc: + raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc + return Response.success(report) + + +@router.get("/characters/{character_id}/outfits/{outfit_id}", response_model=Response[dict]) +def get_outfit_asset( + character_id: int, + outfit_id: str, + request: Request, + session: Session = Depends(get_session), +) -> Response[dict]: + user_id = request.state.current_user.id + character = get_character_with_auth(session, character_id, user_id) + _outfit_or_raise(character, outfit_id) + view = _operations(request).view(_asset_key(character_id, outfit_id)) + _sync_model_url(session, character, outfit_id, view["model_3d_url"]) + return Response.success(view) + + +@router.post("/characters/{character_id}/outfits/{outfit_id}/build", response_model=Response[dict]) +def build_outfit_asset( + character_id: int, + outfit_id: str, + request: Request, + session: Session = Depends(get_session), +) -> Response[dict]: + """启动图生 3D。**按次计费的触发点**,所以只认用户的显式请求,不在任何自动路径上。""" + user_id = request.state.current_user.id + character = get_character_with_auth(session, character_id, user_id) + outfit = _outfit_or_raise(character, outfit_id) + operations = _operations(request) + try: + return Response.success( + operations.build(_asset_key(character_id, outfit_id), + _master_url_or_raise(outfit)), + message="已开始生成 3D 模型", + ) + except ValueError as exc: + raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc + + +@router.post("/characters/{character_id}/outfits/{outfit_id}/approve", response_model=Response[dict]) +def approve_outfit_asset( + character_id: int, + outfit_id: str, + request: Request, + session: Session = Depends(get_session), +) -> Response[dict]: + """人看过模型并点头 → 继续绑骨。**唯一的放行入口**,没有超时自动放行。""" + user_id = request.state.current_user.id + character = get_character_with_auth(session, character_id, user_id) + outfit = _outfit_or_raise(character, outfit_id) + try: + view = _operations(request).approve( + _asset_key(character_id, outfit_id), _master_url_or_raise(outfit) + ) + except ValueError as exc: + raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc + return Response.success(view, message="已放行,开始绑骨") + + +@router.post("/characters/{character_id}/outfits/{outfit_id}/discard", response_model=Response[dict]) +def discard_outfit_asset( + character_id: int, + outfit_id: str, + request: Request, + session: Session = Depends(get_session), +) -> Response[dict]: + """模型不合格 → 丢弃重来。混元的模型改不动,这是唯一的补救。""" + user_id = request.state.current_user.id + character = get_character_with_auth(session, character_id, user_id) + _outfit_or_raise(character, outfit_id) + try: + view = _operations(request).discard(_asset_key(character_id, outfit_id)) + except ValueError as exc: + raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc + return Response.success(view, message="已丢弃待审模型") diff --git a/backend/tests/test_render3d_asset_endpoints.py b/backend/tests/test_render3d_asset_endpoints.py new file mode 100644 index 00000000..8dc86dfa --- /dev/null +++ b/backend/tests/test_render3d_asset_endpoints.py @@ -0,0 +1,355 @@ +"""建 3D 资产的四个端点:看状态 / 建 / 放行 / 否掉。 + +锁的是**钱和闸**,不是"能返回 200": + + ① 按次计费的触发点只有一个,且成本恒在返回里 —— 用户不可能在不知情时触发; + ② 人工确认闸不点头就不绑骨。这道闸一旦能自动放行,一个坏模型会连带浪费绑骨的 + 10 积分和之后所有出帧,而人要看完一整套序列帧才发现锅在最上游; + ③ 母版没过预检就不许花钱建 —— 母版 ¥0.29、模型 ¥2.40,错要在便宜的地方纠。 +""" +from __future__ import annotations + +import io +import pathlib + +import pytest +from PIL import Image + +from windup_app.server.orchestrator.render3d_assets import ( + BUILD_CREDITS, + LocalDirAssetStore, + LocalDirModelReview, + Render3DAssetBuilder, +) +from windup_app.server.orchestrator.render3d_service import Render3DAssetOperations +from windup_app.server.character.model import Character +from windup_app.server.project.model import Project + +MASTER_URL = "https://cdn.windup.test/media/reference-image/master.png" +OUTFIT_ID = "outfit-default" + + +def _master_png(*, legs_apart: bool = True) -> bytes: + """一个能过预检的人形。腿之间那道空隙决定 ``limb_segments``。""" + img = Image.new("RGBA", (200, 400), (0, 0, 0, 0)) + px = img.load() + + def block(x0, y0, x1, y1): + for y in range(y0, y1): + for x in range(x0, x1): + px[x, y] = (40, 40, 60, 255) + + block(80, 40, 120, 100) + block(70, 100, 130, 240) + block(74, 240, 94, 380) + block(106, 240, 126, 380) + if not legs_apart: + block(94, 240, 106, 380) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _FakeModel3D: + """图生 3D 的替身。**记账**:调了几次就是花了几次钱。""" + + def __init__(self) -> None: + self.calls = 0 + + def image_to_3d(self, master: bytes, *, want: str = "GLB") -> bytes: + self.calls += 1 + return b"glTF-fake-model" + + +class _FakeAutoRig: + def __init__(self) -> None: + self.calls = 0 + + def rig(self, model: bytes, *, want: str = "GLB", motion=None): + self.calls += 1 + return _Rigged(model + b"-rigged", "GLB") + + +class _Rigged: + def __init__(self, data: bytes, fmt: str) -> None: + self.data, self.fmt = data, fmt + + +@pytest.fixture() +def render3d(tmp_path: pathlib.Path): + """就地跑完的 operations —— 不起线程,用例不必等收敛。 + + 等线程的用例会变成偶发失败,而偶发失败最后都会被人当噪音忽略。 + """ + model3d, autorig = _FakeModel3D(), _FakeAutoRig() + store = LocalDirAssetStore(tmp_path / "assets") + builder = Render3DAssetBuilder( + model3d=model3d, + autorig=autorig, + store=store, + review=LocalDirModelReview(tmp_path / "review"), + may_build_assets=True, + ) + published: list[bytes] = [] + + def publish(data: bytes) -> str: + published.append(data) + return f"https://cdn.windup.test/media/model-3d/{len(published)}.glb" + + source = {"master": _master_png()} # 用例要换母版时改这里,不碰私有属性 + operations = Render3DAssetOperations( + builder, store, publish, + fetch=lambda url: source["master"], + spawn=lambda work: work(), + ) + operations.test_model3d = model3d # 用例要按住"调了几次" + operations.test_autorig = autorig + operations.test_source = source + return operations + + +@pytest.fixture() +def api(auth_client, engine, render3d): + """带一个已确认定妆母版的造型的角色 + 装好替身的 app。""" + auth_client.app.state.render3d_operations = render3d + from sqlalchemy.orm import sessionmaker + + session = sessionmaker(bind=engine)() + session.add(Project(id=1, user_id=1, project_name="p", character_perspective=1, + directional_movement=1, sprite_width=64, sprite_height=64)) + session.add(Character( + id=7, project_id=1, workflow_run_id=1, name="仙月", + character_data={"version": 1, "outfits": [ + {"id": OUTFIT_ID, "name": "常态造型", "description": None, + "preview_url": MASTER_URL, "model_3d_url": None, "actions": []}, + ]}, + status=0, + )) + session.commit() + session.close() + return auth_client + + +def _base(outfit_id: str = OUTFIT_ID) -> str: + return f"/render3d/characters/7/outfits/{outfit_id}" + + +def _data(response) -> dict: + body = response.json() + assert body["code"] == 200, body + return body["data"] + + +# ── ① 成本:用户不可能在不知情时触发按次计费 ────────────────────────────── + + +def test_status_always_carries_the_cost_even_before_anything_is_built(api, render3d): + data = _data(api.get(_base())) + assert data["state"] == "absent" + assert data["cost"]["model3d_credits"] == 20 + assert data["cost"]["autorig_credits"] == 10 + assert data["cost"]["total_credits"] == BUILD_CREDITS + assert data["cost"]["total_cny"] == pytest.approx(3.60) + assert data["cost"]["scope"] == "per_outfit_once" + assert render3d.test_model3d.calls == 0 # 看一眼状态不花钱 + + +def test_reading_status_is_free_no_matter_how_often(api, render3d): + for _ in range(5): + api.get(_base()) + assert render3d.test_model3d.calls == 0 + assert render3d.test_autorig.calls == 0 + + +def test_cost_numbers_come_from_the_billing_implementation(api): + """成本不是前端抄的常量。改了计费实现而这里没跟着变,说明有人抄了一份数字 —— + 抄的那一份正是给用户看的,告知错的价钱比不告知更糟。""" + from windup_framework.providers.render3d.tencent import ( + CREDIT_PRICE_CNY, + CREDITS, + RIG_CREDITS, + ) + + cost = _data(api.get(_base()))["cost"] + assert cost["model3d_credits"] == CREDITS["Normal"] + assert cost["autorig_credits"] == RIG_CREDITS + assert cost["total_cny"] == pytest.approx( + (CREDITS["Normal"] + RIG_CREDITS) * CREDIT_PRICE_CNY, abs=0.005 + ) + + +# ── ② 人工确认闸 ──────────────────────────────────────────────────────────── + + +def test_build_stops_at_the_review_gate_without_rigging(api, render3d): + """建完 ① 就停。**绑骨一次都不许调** —— 这道闸的全部价值就在这里。""" + data = _data(api.post(f"{_base()}/build")) + assert data["state"] == "awaiting_review" + assert render3d.test_model3d.calls == 1 + assert render3d.test_autorig.calls == 0 + assert _data(api.get(_base()))["state"] == "awaiting_review" + + +def test_waiting_at_the_gate_forever_never_auto_approves(api, render3d): + """反复查状态不会把闸熬开。超时自动放行的闸等于没有闸,只是把"没人看"伪装成 + "看过了"。""" + api.post(f"{_base()}/build") + for _ in range(10): + assert _data(api.get(_base()))["state"] == "awaiting_review" + assert render3d.test_autorig.calls == 0 + + +def test_awaiting_review_hands_out_a_link_to_the_model(api): + """待审模型必须能取到。只躺在服务器磁盘上的话,人点"通过"时其实一眼都没看到, + 闸就退化成一个必须点的按钮 —— 比没有闸更糟,它制造了"已经审过"的假象。""" + data = _data(api.post(f"{_base()}/build")) + assert data["review_model_url"], "待审模型没有可打开的地址" + assert data["model_3d_url"] is None, "还没绑骨,不该有可用的绑骨模型" + + +def test_discarding_also_drops_the_review_link(api): + api.post(f"{_base()}/build") + assert _data(api.post(f"{_base()}/discard"))["review_model_url"] is None + + +def test_approve_is_what_starts_rigging(api, render3d): + api.post(f"{_base()}/build") + data = _data(api.post(f"{_base()}/approve")) + assert data["state"] == "ready" + assert render3d.test_autorig.calls == 1 + assert data["model_3d_url"] + + +def test_approving_before_there_is_a_model_is_refused(api, render3d): + body = api.post(f"{_base()}/approve").json() + assert body["code"] == 400 + assert render3d.test_autorig.calls == 0 + + +def test_discard_sends_it_back_to_absent_and_the_next_build_regenerates(api, render3d): + """不合格 → 丢弃 → 重新生成。混元的模型改不动,这是唯一的补救。""" + api.post(f"{_base()}/build") + assert _data(api.post(f"{_base()}/discard"))["state"] == "absent" + assert render3d.test_autorig.calls == 0 + + assert _data(api.post(f"{_base()}/build"))["state"] == "awaiting_review" + assert render3d.test_model3d.calls == 2 # 重新生成要再付一次图生 3D + + +def test_discard_after_a_failed_rig_clears_the_approval_marker(api, render3d): + """绑骨失败后模型还卡在闸上、而批准标记已经留下了。此时否掉必须把标记一起删 —— + 留着的话,下一次生成出来的新模型会被这枚旧标记直接放行,人一眼都没看到就进了绑骨。""" + def _boom(model: bytes, *, want: str = "GLB", motion=None): + render3d.test_autorig.calls += 1 + raise RuntimeError("绑骨服务 500") + + api.post(f"{_base()}/build") + render3d.test_autorig.rig = _boom + api.post(f"{_base()}/approve") + assert render3d.test_autorig.calls == 1 + + assert _data(api.post(f"{_base()}/discard"))["state"] == "absent" + render3d.test_autorig.rig = _FakeAutoRig().rig.__get__(render3d.test_autorig) + assert _data(api.post(f"{_base()}/build"))["state"] == "awaiting_review", ( + "新模型被旧的批准标记放行了" + ) + + +# ── ③ 复用:每造型一次性,不是每动作一次 ──────────────────────────────────── + + +def test_building_twice_is_refused_instead_of_paying_again(api, render3d): + api.post(f"{_base()}/build") + assert api.post(f"{_base()}/build").json()["code"] == 400 + assert render3d.test_model3d.calls == 1 + + +def test_ready_asset_is_not_rebuilt(api, render3d): + api.post(f"{_base()}/build") + api.post(f"{_base()}/approve") + assert api.post(f"{_base()}/build").json()["code"] == 400 + assert render3d.test_model3d.calls == 1 + assert render3d.test_autorig.calls == 1 + + +def test_ready_asset_writes_the_url_back_onto_the_outfit(api, engine): + """不回写的话,三渲二的判据(``outfits[].model_3d_url``)永远是 None —— + 资产建好了,前端依旧显示"该造型暂无绑骨 3D 模型",钱白花。""" + api.post(f"{_base()}/build") + api.post(f"{_base()}/approve") + api.get(_base()) # 回写发生在读状态这一步 + + from sqlalchemy.orm import sessionmaker + + session = sessionmaker(bind=engine)() + stored = session.get(Character, 7) + outfit = stored.character_data["outfits"][0] + session.close() + assert outfit["model_3d_url"], "建好的模型 URL 没落到造型上" + + +# ── ④ 母版预检把关花钱那一步 ──────────────────────────────────────────────── + + +def test_unusable_master_is_refused_before_any_paid_call(api, render3d): + """空白母版 → 拒绝建。母版 ¥0.29、模型 ¥2.40,错要在便宜的地方纠。""" + blank = Image.new("RGBA", (200, 200), (0, 0, 0, 0)) + buf = io.BytesIO() + blank.save(buf, "PNG") + render3d.test_source["master"] = buf.getvalue() + + assert api.post(f"{_base()}/build").json()["code"] == 400 + assert render3d.test_model3d.calls == 0 + + +def test_a_warned_but_usable_master_still_builds(api, render3d): + """警告不拦路:两条警告判据都会在合法母版上误报(侧视角色两腿必然重叠), + 拿它们挡路等于把误报变成挡住用户的钱。""" + render3d.test_source["master"] = _master_png(legs_apart=False) + assert _data(api.post(f"{_base()}/build"))["state"] == "awaiting_review" + assert render3d.test_model3d.calls == 1 + + +def test_precheck_endpoint_reports_facts_and_warnings_without_spending(api, render3d): + api.app.state.precheck_master = lambda url, canvas=None: { + "accepted": True, "reject_code": None, "detail": "ok", + "facts": {"limb_segments": [1, 1, 1, 1]}, + "warnings": [{"code": "limbs_fused", "detail": "两腿量不到空隙"}], + } + data = _data(api.post("/render3d/master-precheck", json={"image_url": MASTER_URL})) + assert data["accepted"] is True + assert data["warnings"][0]["code"] == "limbs_fused" + assert render3d.test_model3d.calls == 0 + + +# ── ⑤ 归属与键 ────────────────────────────────────────────────────────────── + + +def test_unknown_outfit_is_not_found(api): + assert api.get(_base("outfit-nope")).json()["code"] == 404 + + +def test_other_users_character_is_not_reachable(auth_client_b, api): + assert auth_client_b.get(_base()).json()["code"] == 404 + + +def test_asset_key_is_namespaced_by_character(api, render3d, engine): + """``outfit-default`` 是工作流写死给首个造型的 id。只用它当落点键的话, + 全站每个角色的默认造型会共用同一个 3D 模型,且没有任何报错。""" + api.post(f"{_base()}/build") + from sqlalchemy.orm import sessionmaker + + session = sessionmaker(bind=engine)() + session.add(Character( + id=8, project_id=1, workflow_run_id=2, name="另一个", + character_data={"version": 1, "outfits": [ + {"id": OUTFIT_ID, "name": "常态造型", "description": None, + "preview_url": MASTER_URL, "model_3d_url": None, "actions": []}, + ]}, + status=0, + )) + session.commit() + session.close() + + other = api.get(f"/render3d/characters/8/outfits/{OUTFIT_ID}") + assert _data(other)["state"] == "absent", "另一个角色的默认造型不该套用这一个的模型" diff --git a/openapi.json b/openapi.json index d2d7bfcb..e50328d8 100644 --- a/openapi.json +++ b/openapi.json @@ -143,6 +143,17 @@ "title": "Num Frames", "type": "integer" }, + "outfit_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Outfit Id" + }, "project_id": { "exclusiveMinimum": 0.0, "title": "Project Id", @@ -453,6 +464,18 @@ "title": "Id", "type": "string" }, + "model_3d_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "该造型的绑骨 3D 模型 URL;None = 未建,三渲二不可用", + "title": "Model 3D Url" + }, "name": { "description": "造型名称", "title": "Name", @@ -998,6 +1021,45 @@ "title": "LoginRequest", "type": "object" }, + "MasterPrecheckRequest": { + "description": "要预检的母版。只收自家对象存储的 URL —— 服务端替调用方拉任意地址等于把服务器\n当跳板,见 ``orchestrator._fetch``。", + "properties": { + "canvas_height": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Canvas Height" + }, + "canvas_width": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Canvas Width" + }, + "image_url": { + "minLength": 1, + "title": "Image Url", + "type": "string" + } + }, + "required": [ + "image_url" + ], + "title": "MasterPrecheckRequest", + "type": "object" + }, "MediaCategory": { "description": "上传文件的业务分类,用于生成对象存储 key 的目录。\n\n放在 common 而非 media 模块:新增文件用途时无需修改 media 代码。", "enum": [ @@ -1671,6 +1733,50 @@ "title": "Response[WorkflowRunOut]", "type": "object" }, + "Response_dict_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "业务数据", + "title": "Data" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + } + }, + "title": "Response[dict]", + "type": "object" + }, "SendCodeRequest": { "description": "发送验证码请求。", "properties": { @@ -3154,6 +3260,255 @@ ] } }, + "/render3d/characters/{character_id}/outfits/{outfit_id}": { + "get": { + "operationId": "get_outfit_asset_render3d_characters__character_id__outfits__outfit_id__get", + "parameters": [ + { + "in": "path", + "name": "character_id", + "required": true, + "schema": { + "title": "Character Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "outfit_id", + "required": true, + "schema": { + "title": "Outfit Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Outfit Asset", + "tags": [ + "render3d" + ] + } + }, + "/render3d/characters/{character_id}/outfits/{outfit_id}/approve": { + "post": { + "description": "人看过模型并点头 → 继续绑骨。**唯一的放行入口**,没有超时自动放行。", + "operationId": "approve_outfit_asset_render3d_characters__character_id__outfits__outfit_id__approve_post", + "parameters": [ + { + "in": "path", + "name": "character_id", + "required": true, + "schema": { + "title": "Character Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "outfit_id", + "required": true, + "schema": { + "title": "Outfit Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Approve Outfit Asset", + "tags": [ + "render3d" + ] + } + }, + "/render3d/characters/{character_id}/outfits/{outfit_id}/build": { + "post": { + "description": "启动图生 3D。**按次计费的触发点**,所以只认用户的显式请求,不在任何自动路径上。", + "operationId": "build_outfit_asset_render3d_characters__character_id__outfits__outfit_id__build_post", + "parameters": [ + { + "in": "path", + "name": "character_id", + "required": true, + "schema": { + "title": "Character Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "outfit_id", + "required": true, + "schema": { + "title": "Outfit Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Build Outfit Asset", + "tags": [ + "render3d" + ] + } + }, + "/render3d/characters/{character_id}/outfits/{outfit_id}/discard": { + "post": { + "description": "模型不合格 → 丢弃重来。混元的模型改不动,这是唯一的补救。", + "operationId": "discard_outfit_asset_render3d_characters__character_id__outfits__outfit_id__discard_post", + "parameters": [ + { + "in": "path", + "name": "character_id", + "required": true, + "schema": { + "title": "Character Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "outfit_id", + "required": true, + "schema": { + "title": "Outfit Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Discard Outfit Asset", + "tags": [ + "render3d" + ] + } + }, + "/render3d/master-precheck": { + "post": { + "description": "零成本母版预检。**不产生任何按次计费调用**,可以在确认闸上随便调。", + "operationId": "precheck_master_render3d_master_precheck_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MasterPrecheckRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Precheck Master", + "tags": [ + "render3d" + ] + } + }, "/workflow-runs": { "get": { "description": "分页查询项目下的执行记录。", From aed2a09618878fbdbe13664a36d209309ea61844 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 17 Aug 2026 15:01:41 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(render3d):=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E6=88=90=E6=9C=AC=E8=AE=BA=E8=AF=81=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=AE=9A=E6=80=A7=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai_engine/src/windup_ai_engine/master_check.py | 2 +- .../windup_app/server/orchestrator/render3d_assets.py | 10 +++++----- .../windup_app/server/orchestrator/render3d_service.py | 8 ++++---- backend/tests/test_render3d_asset_endpoints.py | 6 +++--- backend/tests/test_render3d_route_and_assets.py | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py index d8c739d0..7d6d8146 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -19,7 +19,7 @@ 拒绝与警告的分界**由判据能不能证伪决定,不由后果严重程度决定**。④⑤ 指向的是混元图生 3D 的硬约束(四肢粘连 → 绑骨绑不出腿;画面里有武器配件 → 明确不允许),后果比 ③ 更贵, 但两条判据都会在合法母版上误报,所以只能警告。**上层拿它们做什么决定**:摆在母版确认闸 -上给人看,让人在花 ¥2.40 建 3D 之前决定"就用这张 / 重新生成三张";任何一条都不阻断流程。 +上给人看,让人在付费建 3D 之前决定"就用这张 / 重新生成三张";任何一条都不阻断流程。 **本层不判什么、为什么 —— 别把下面这些当成已经守住了:** - **画面里有没有文字**(提示词含 "reference sheet" 时生图模型会自己糊上标注,烤进母版 diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py index f2fafa94..da9f998d 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py @@ -161,7 +161,7 @@ class ModelReviewGate(Protocol): 为什么这一道非要有:混元生成的 3D 模型**没法事后好好修改**,等于"生成即最终" —— 拓扑、绑点、配件都在生成那一步定死。所以模型不合格时唯一的补救是重新生成,而不是 - 修它。若管线一口气从图生 3D 冲到绑骨+出帧,一个坏模型会连带浪费掉绑骨的 10 积分和 + 修它。若管线一口气从图生 3D 冲到绑骨+出帧,一个坏模型会连带浪费掉绑骨那笔计费和 后面所有出帧,人还要看完一整套序列帧才发现问题出在最上游那一步。 把停点放在图生 3D **之后、绑骨之前**,是因为这里是信息最全而花费最少的位置: @@ -278,9 +278,9 @@ def approve(self, outfit_key: str) -> None: def discard(self, outfit_key: str) -> None: """人否掉待审模型:删待审件,回到 ``ABSENT``,下次 :meth:`ensure` 重新生成。 - **注意这一步的代价**:重新生成要再付一次图生 3D 的 20 积分。之所以还是删, + **注意这一步的代价**:重新生成要再付一次图生 3D。之所以还是删, 是因为混元的模型改不动(生成即最终),留着一个不合格的模型只有两种下场 —— - 要么被误放行进绑骨(再赔 10 积分和之后所有出帧),要么永远卡在闸上。 + 要么被误放行进绑骨(再赔一次绑骨计费和之后所有出帧),要么永远卡在闸上。 """ self._store.delete(f"{RAW_KEY_PREFIX}{outfit_key}") self._review.discard(outfit_key) @@ -288,7 +288,7 @@ def discard(self, outfit_key: str) -> None: def ensure(self, outfit_key: str, master: bytes, progress: ProgressPort) -> bytes: """取该造型的绑骨模型;没有且获准时才现建。 - 建一次约 ¥3.60(图生 3D 20 积分 + 绑骨 10 积分 × ¥0.12),**每造型一次性**。 + 建一次的计费 = 图生 3D + 绑骨,取值见本模块顶部常量,**每造型一次性**。 ``may_build_assets=False``(默认)时不建 —— 一个 web 请求不该顺手扣这笔钱, 那正是"无人值守烧钱"。要放开就显式设 ``WINDUP_RENDER3D_ALLOW_SPEND``。 """ @@ -339,7 +339,7 @@ def _build(self, key: str, master: bytes, progress: ProgressPort) -> bytes: "不合格则删掉待审模型重新生成 —— 混元的模型改不动,只能重生成", ) - progress.step("assets", 1, 2, "模型已确认,自动绑骨(按次计费,10 积分)") + progress.step("assets", 1, 2, "模型已确认,自动绑骨(按次计费)") rigged: RiggedModel = self._autorig.rig(model, want="GLB") # 存的是**绑骨后**的产物:它是渲帧真正要的那个,存中间的 model 等于下次还得再绑一次。 diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py index ca20e750..60fc17ad 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py @@ -12,9 +12,9 @@ ━━ 为什么母版这道闸比模型那道更值钱 ━━ -一张母版约 ¥0.29,图生 3D 一次 ¥2.40。母版不合格 → 模型必然不合格,而混元的模型 +重出一张母版比重建一次 3D 便宜一个量级。母版不合格 → 模型必然不合格,而混元的模型 **生成即最终**(拓扑、绑点在生成那一步定死,事后改不动),只能重来。所以同一个错误, -在母版处纠成本是 ¥0.29,在模型处纠是 ¥2.40 + 已花的 ¥0.29。闸要放在最便宜的位置。 +在模型处纠正的代价是母版处的数倍,还要叠加已经花掉的那次母版。闸放在最便宜的位置。 """ from __future__ import annotations @@ -145,8 +145,8 @@ def __init__( def view(self, outfit_key: str) -> dict: """状态 + 成本。**不花钱、无副作用**,可以随便轮询。 - 成本恒在返回里,不只在"还没建"的时候给:前端拿它渲染那条"20+10 积分、 - 约 ¥3.60"的提示,而按次计费的触发点绝不能出现在用户不知情的时候。 + 成本恒在返回里,不只在"还没建"的时候给:前端拿它渲染扣费提示, + 而按次计费的触发点绝不能出现在用户不知情的时候。 """ state = self._builder.state(outfit_key) with self._lock: diff --git a/backend/tests/test_render3d_asset_endpoints.py b/backend/tests/test_render3d_asset_endpoints.py index 8dc86dfa..51f2f47d 100644 --- a/backend/tests/test_render3d_asset_endpoints.py +++ b/backend/tests/test_render3d_asset_endpoints.py @@ -4,8 +4,8 @@ ① 按次计费的触发点只有一个,且成本恒在返回里 —— 用户不可能在不知情时触发; ② 人工确认闸不点头就不绑骨。这道闸一旦能自动放行,一个坏模型会连带浪费绑骨的 - 10 积分和之后所有出帧,而人要看完一整套序列帧才发现锅在最上游; - ③ 母版没过预检就不许花钱建 —— 母版 ¥0.29、模型 ¥2.40,错要在便宜的地方纠。 + 绑骨那笔计费和之后所有出帧,而人要看完一整套序列帧才发现锅在最上游; + ③ 母版没过预检就不许花钱建 —— 重出母版比重建模型便宜一个量级,错要在便宜的地方纠。 """ from __future__ import annotations @@ -292,7 +292,7 @@ def test_ready_asset_writes_the_url_back_onto_the_outfit(api, engine): def test_unusable_master_is_refused_before_any_paid_call(api, render3d): - """空白母版 → 拒绝建。母版 ¥0.29、模型 ¥2.40,错要在便宜的地方纠。""" + """空白母版 → 拒绝建。重出母版比重建模型便宜一个量级,错要在便宜的地方纠。""" blank = Image.new("RGBA", (200, 200), (0, 0, 0, 0)) buf = io.BytesIO() blank.save(buf, "PNG") diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index 6e5ec503..30b08b8b 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -475,7 +475,7 @@ def test_get_costs_nothing(tmp_path): def test_default_posture_refuses_to_build_assets(tmp_path): - """默认不授权花钱:一个 web 请求不该顺手扣 ¥3.60。""" + """默认不授权花钱:一个 web 请求不该顺手触发按次计费。""" builder, m, r = _builder(tmp_path, may_build=False) with pytest.raises(ValueError, match="未获准建"): builder.ensure(OUTFIT, _png(), _NullProgress())