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 f70a8f115..ad8195198 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -54,6 +54,11 @@ from windup_app.server.mq import i2v_admit from windup_app.server.orchestrator.signals import ActionAwaitingAdmit, ActionRateLimited from windup_framework.gateway.errors import RateLimitBackoff, UpstreamExhaustedError +from windup_app.server.orchestrator.render3d_assets import ( + AUTORIG_CREDITS, + BUILD_MOTION, + RENDERABLE_ACTIONS, +) from windup_app.server.orchestrator.model import ( ActionType, CharacterActionInput, @@ -495,6 +500,16 @@ def _produce_action( # # 选哪个 kling 不在这里传:run_action_task 已经 bind_call_context(start_from_model)。 model_url = (input.model_3d_url or "").strip() + # 一份绑骨产物只带**一个**动作片段(绑骨接口一次只吃一个 MotionType), + # 建资产时烘的是 BUILD_MOTION。别的动作在这里当场拒,**不能派给出帧台** —— + # 那唯一一个片段照样能渲满 32 张帧,于是攻击任务收到的是一段走路,而帧数、 + # 时长、朝向、成色全部自洽,没有任何一道会红。也不回落 i2v(见下)。 + if model_url and input.action_type.value not in RENDERABLE_ACTIONS: + raise ValueError( + f"该造型的 3D 资产只烘了 {BUILD_MOTION!r} 一个动作片段,出不了 " + f"{input.action_type.value!r}。要走三渲二得为这个动作再绑一次骨" + f"({AUTORIG_CREDITS} 积分);要现在就出,把这个动作交给 i2v。" + ) # 三渲二那支不取母版,而出口的判官闸口要拿它当参照 —— 不先置 None 的话那支会 # 撞 UnboundLocalError,而它只在有 3D 资产的造型上触发。 master: bytes | None = None 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 78d48125e..3dd1944e5 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,6 +39,7 @@ import hashlib import logging import pathlib +from collections.abc import Mapping from enum import Enum from typing import TYPE_CHECKING, Protocol, runtime_checkable @@ -69,6 +70,49 @@ AUTORIG_CREDITS = RIG_CREDITS BUILD_CREDITS = MODEL3D_CREDITS + AUTORIG_CREDITS +# ── 产品动作 → 绑骨预设动作 ───────────────────────────────────────────────── +# +# 绑骨接口一次只吃一个 ``MotionType``,**一次绑骨 = 一个动作片段**。所以这张表回答的是 +# "某个产品动作该烘哪个预设",而不是"一份资产能出哪些动作"(后者恒为一个)。 +# +# 值为 ``None`` 表示**这条路线不接这个动作**,不是待补: +# +# - ``attack``:预设库里只有 thrust(16)/ kick(18) 两个近似项,而产品的攻击按运动 +# 拓扑分四型(sweep / thrust / project / lunge,见 ``AttackArchetype``)。拿 thrust +# 顶 sweep 会渲出一段"直刺"冒充"横挥" —— 帧数、时长、成色全部正常,没有一道会红。 +# 要接它得先实测出 sweep 类预设的编号,那是另一件事。 +# - ``custom``:用户自述动作,预设库里按定义就没有对应项;映射到任何一个预设都是拿 +# 别的动作冒充。 +# +# 两者继续走 i2v —— 那条路线本来就吃自然语言描述,不是降级。 +# +# 键取 ``orchestrator.model.ActionType`` 的取值(不 import 那个枚举:本模块是准备整体 +# 搬去产品仓的,它不该依赖 ORM 层)。取值域由 ``test_render3d_motion`` 钉住。 +ACTION_MOTIONS: Mapping[str, str | None] = { + "walk": "walk", + "idle": "idle", + "jump": "jump", + "attack": None, + "custom": None, +} + +# 建资产时烘进模型的那个动作。**是常量,不是部署开关。** +# +# 一份绑骨产物只带一个动作片段,"这份资产会哪个动作"必须与它真实烘进去的那个永远一致。 +# 做成开关的话,改开关会让**已经建好的**资产开始声称自己会另一个动作,而渲出来的仍是 +# 旧的那段 —— 又是一次"帧数、时长、成色全正常"的静默错。要按造型选动作,得先有一个 +# 能承载"每动作一份资产"的落点与 URL 字段(见本模块开头的成本说明)。 +# +# 选 walk 而不是 idle:走 / 跑正是另外两条路线做不好的部分(i2v 各朝向互不一致、逐帧 +# 路线腿不左右交替),而 idle 已经由 i2v 覆盖得不错。 +BUILD_MOTION = "walk" + +# 走本路线出得了的动作。资产只烘了 ``BUILD_MOTION``,别的动作**必须在派单之前被拒**: +# 模型里那唯一一个片段照样渲得出 32 张帧,拿走路帧当攻击交付不会有任何一处报错。 +RENDERABLE_ACTIONS = frozenset( + action for action, motion in ACTION_MOTIONS.items() if motion == BUILD_MOTION +) + class Render3DAssetState(str, Enum): """一个造型的 3D 资产处在哪一步。**状态由落点推出来,不单独存一份** —— @@ -309,10 +353,15 @@ def ensure(self, outfit_key: str, master: bytes, progress: ProgressPort) -> byte # ── 内部 ───────────────────────────────────────────────────────────── def _build(self, key: str, master: bytes, progress: ProgressPort) -> bytes: - """① 图生 3D →(人工确认)→ ② 绑骨。**按次计费,每造型一次性。** + """① 图生 3D →(人工确认)→ ② 绑骨并烘入 :data:`BUILD_MOTION`。**按次计费,每造型一次性。** 中间那道人工确认是硬停点,原因见 :class:`ModelReviewGate`:模型不可事后修改, 坏模型只能重生成,所以要在**花绑骨的钱之前**让人看一眼。 + + **绑骨必须带动作。** 不带 ``MotionType`` 的请求接口照样受理、照样扣 10 积分, + 只是产物里零个动画片段(实测:带 walk 的产物 1 个 AnimationStack,不带的 0 个; + 图生 3D 出的原始模型本身也是 0 个)。出帧台拿到零片段就出不了动作,而绑骨、 + 取件、落点每一步都"成功"。 """ raw_key = f"{RAW_KEY_PREFIX}{key}" @@ -337,8 +386,16 @@ def _build(self, key: str, master: bytes, progress: ProgressPort) -> bytes: "不合格则删掉待审模型重新生成 —— 混元的模型改不动,只能重生成", ) - progress.step("assets", 1, 2, "模型已确认,自动绑骨(按次计费)") - rigged: RiggedModel = self._autorig.rig(model, want="GLB") + progress.step("assets", 1, 2, f"模型已确认,自动绑骨并烘入 {BUILD_MOTION} 动作(按次计费)") + rigged: RiggedModel = self._autorig.rig(model, want="GLB", motion=BUILD_MOTION) + if rigged.motion is None: + # 不带动作的绑骨产物在下游**每一道闸前都是正常的**:格式对、28 骨对、体积对, + # 只是出帧台拿到零个动画片段。存下来就等于把 10 积分买来的哑模型挂成 READY, + # 用户侧的症状是"三渲二根本出不了动作",而没有任何一处报错指向绑骨这一步。 + raise RuntimeError( + f"绑骨产物里没有动作片段(请求的是 {BUILD_MOTION!r})—— 拒绝当成就绪资产存下。" + "绑骨请求体缺 MotionType 时接口照样成功、照样扣费,产物只是零动画。" + ) # 存的是**绑骨后**的产物:它是渲帧真正要的那个,存中间的 model 等于下次还得再绑一次。 self._store.put(key, rigged.data) 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 e6dda8d8d..5b58fb3c2 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 @@ -351,17 +351,32 @@ def rig(self, model: bytes, *, want: str = "GLB", motion=None): def _publish_model(data: bytes) -> str: - """把绑骨模型放到对象存储,拿到 ``outfits[].model_3d_url`` 要的那个 URL。""" - from windup_app.server.media.model import MediaUploadInput + """把绑骨模型放到对象存储,拿到 ``outfits[].model_3d_url`` 要的那个 URL。 + + 后缀按 **magic bytes** 定,不写死 ``.glb``:绑骨接口即便被要求 GLB 也返回 FBX + (实测归档里每一份绑骨产物都是 FBX),而两个出帧台宿主都是**按 URL 后缀挑 loader** + 的 —— FBX 挂成 ``.glb`` 会让它走 GLTFLoader,报一句 "Bad glTF",排查方向整个跑偏到 + 出帧台,而钱早就花完了。``Type`` 是供应商的自述,magic 才是事实。 + """ + from windup_framework.providers.render3d import sniff_format + + from windup_app.server.media.model import MediaCategory, MediaUploadInput from windup_app.server.media.service import service as media_service + ext, content_type = ( + ("fbx", "application/x-fbx") + if sniff_format(data) == "FBX" + else ("glb", "model/gltf-binary") + ) return media_service.upload( data, MediaUploadInput( - filename="rigged.glb", - content_type="model/gltf-binary", + filename=f"rigged.{ext}", + content_type=content_type, size=len(data), - category="model-3d", + # 取枚举成员而不是字面量:分类是 pydantic 强校验的,字面量打错会在**两笔钱 + # 都花完之后**才炸,而错误文本只说"输入不合法"。 + category=MediaCategory.MODEL_3D, ), ).url diff --git a/backend/packages/common/src/windup_common/enums/media.py b/backend/packages/common/src/windup_common/enums/media.py index ee9b38aec..e8de92f27 100644 --- a/backend/packages/common/src/windup_common/enums/media.py +++ b/backend/packages/common/src/windup_common/enums/media.py @@ -12,4 +12,9 @@ class MediaCategory(StrEnum): REFERENCE_IMAGE = "reference-image" OUTFIT_PREVIEW = "outfit-preview" ACTION_FRAME = "action-frame" + # 造型的绑骨 3D 模型。**这个成员之前一直缺**,而 ``render3d_service._publish_model`` + # 一直按 ``"model-3d"`` 上传 —— 于是每次建 3D 资产都在图生 3D + 绑骨都付完之后 + # 撞 ValidationError:模型上传不出去、``outfits[].model_3d_url`` 永远是 None、 + # 三渲二那条路线在生产里一次都选不中。没有一处日志写着"分类不存在"。 + MODEL_3D = "model-3d" GENERAL = "general" diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs b/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs index da2a6d3ae..93fbcb274 100644 --- a/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs +++ b/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs @@ -47,8 +47,13 @@ try { const clips = await page.evaluate(() => window.__clips()); const names = Object.keys(clips); if (!names.length) throw new Error('模型里没有任何动画片段 —— 绑骨时没带 MotionType?'); - const clip = CLIP && CLIP.length ? CLIP : names[0]; - if (!clips[clip]) throw new Error(`模型里没有片段 ${JSON.stringify(clip)};有的是 ${JSON.stringify(names)}`); + // 片段名由绑骨接口的动作库自己起(实测 `Armature|32795ddb244644eac67ccfd8b84060c3_remap`), + // 对不上产品动作名是常态。只有一个片段时就用它 —— 只有一个候选就不存在"选错"。多于一个还对不上名字仍然 + // 抛:那时候选谁都是猜,而猜错会渲出另一个动作,帧数、时长、成色全部正常。 + // 与浏览器那份宿主的 resolveClip 同一条规则,两边分叉不会报错,只会渲出不同的东西。 + const want = CLIP && CLIP.length ? CLIP : names[0]; + const clip = clips[want] ? want : (names.length === 1 ? names[0] : null); + if (!clip) throw new Error(`模型里没有片段 ${JSON.stringify(want)};有的是 ${JSON.stringify(names)}`); const rig = await page.evaluate(() => window.__rigInfo()); const rootMotion = await page.evaluate(() => window.__rootMotion()); diff --git a/backend/tests/test_render3d_asset_endpoints.py b/backend/tests/test_render3d_asset_endpoints.py index 8a3d315a5..a7273178d 100644 --- a/backend/tests/test_render3d_asset_endpoints.py +++ b/backend/tests/test_render3d_asset_endpoints.py @@ -64,17 +64,23 @@ def image_to_3d(self, master: bytes, *, want: str = "GLB") -> bytes: class _FakeAutoRig: + """绑骨的替身。**照真接口的行为回 motion**:不传 MotionType 的请求接口照样受理、 + 照样扣费,只是产物零动画 —— 替身把 ``motion`` 恒填成非空的话,"忘了传动作"这个 + 缺陷在所有用例里都不可见。""" + def __init__(self) -> None: self.calls = 0 + self.motions: list[str | int | None] = [] def rig(self, model: bytes, *, want: str = "GLB", motion=None): self.calls += 1 - return _Rigged(model + b"-rigged", "GLB") + self.motions.append(motion) + return _Rigged(model + b"-rigged", "GLB", motion) class _Rigged: - def __init__(self, data: bytes, fmt: str) -> None: - self.data, self.fmt = data, fmt + def __init__(self, data: bytes, fmt: str, motion=None) -> None: + self.data, self.fmt, self.motion = data, fmt, motion @pytest.fixture() diff --git a/backend/tests/test_render3d_motion.py b/backend/tests/test_render3d_motion.py new file mode 100644 index 000000000..4a937ee04 --- /dev/null +++ b/backend/tests/test_render3d_motion.py @@ -0,0 +1,282 @@ +"""三渲二**能不能出动作**这条链上的四个静默缺陷。 + +这一片全部是"不报错、只出错东西"的类型,所以每条用例都从"它拦住了哪个真实坏例"写起: + + ① **绑骨请求体里没有 MotionType。** 接口照样受理、照样扣 10 积分,产物只是零个 + AnimationStack(实测:带 walk 的产物 1 个,不带的 0 个;图生 3D 的原始模型也是 0 个)。 + 绑骨"成功"、格式对、28 骨对、体积对,出帧台却拿到零片段 —— 没有任何一道会红。 + ② **零动画的产物被存成 READY。** 落点里有 bytes 就是"资产已就绪",于是 10 积分买来 + 一个哑模型挂在造型上,用户看到的是"三渲二根本出不了动作"。 + ③ **FBX 挂成 .glb 发出去。** 绑骨接口即便被要求 GLB 也返回 FBX,而两个出帧台宿主都 + 按 URL 后缀挑 loader —— 后缀骗了 loader,报一句 "Bad glTF",排查方向整个跑偏。 + ④ **拿走路帧当攻击交付。** 一份绑骨产物只带一个动作片段,那唯一一个片段照样渲得满 + 32 张帧;帧数、时长、朝向、成色全部自洽。 +""" +from __future__ import annotations + +import pathlib + +import pytest + +from windup_app.server.orchestrator.client_bake import ActionAwaitingClientBake +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 ( + ACTION_MOTIONS, + BUILD_MOTION, + RENDERABLE_ACTIONS, + LocalDirAssetStore, + Render3DAssetBuilder, + Render3DAssetState, +) +from windup_common.models import GenRoute + +try: + from windup_framework.providers.render3d import PRESET_MOTIONS, RiggedModel + from windup_framework.providers.render3d import tencent as tencent_mod +except ModuleNotFoundError as exc: # pragma: no cover - 缺件时整体跳过 + pytest.skip(f"缺 {exc.name}(三渲二 provider 层)", allow_module_level=True) + + +GLB_BYTES = b"glTF" + b"\x02\x00\x00\x00" + b"fake-model-payload" +FBX_BYTES = b"Kaydara FBX Binary \x00" + b"fake-rigged-payload" +OUTFIT_KEY = "character-1/outfit-default" + + +class _NullProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + pass + + +class _AutoApproveReview: + """只用于不测人工确认闸的用例 —— 那道闸本身在 test_render3d_asset_endpoints 里有专门用例。""" + + def submit(self, key: str, model: bytes, fmt: str) -> str: + return f"/{key}.{fmt.lower()}" + + def is_approved(self, key: str) -> bool: + return True + + +class _FakeModel3D: + def image_to_3d(self, master: bytes, *, want: str = "GLB", extra_views=None) -> bytes: + return GLB_BYTES + + +# ══ ① 绑骨请求体里到底有没有 MotionType ═══════════════════════════════════════ + + +def test_build_puts_motiontype_in_the_actual_rig_request(tmp_path: pathlib.Path, monkeypatch): + """从"点建资产"一路走到**发给腾讯的那个 params**,里面必须有 MotionType。 + + 拦的坏例:``Render3DAssetBuilder`` 调 ``rig(model, want="GLB")`` 不传 ``motion``, + 于是 ``resolve_motion(None)`` 回 None、``_submit`` 里那个 ``if preset is not None`` + 不成立、请求体里根本没有 MotionType。绑骨照样成功、照样扣费,产物零动画。 + + **本用例刻意用真的 TencentAutoRigProvider**,只把网络那一层换掉:替身 provider 只能 + 证明"builder 传了一个叫 motion 的参数",证明不了"它最后变成了请求体里的 MotionType" + —— 而这条链上任何一环断掉,症状都一模一样。 + """ + sent: list[tuple[str, dict]] = [] + + def fake_call(action: str, params: dict, **_kw) -> dict: + sent.append((action, params)) + if action == "SubmitAutoRiggingJob": + return {"JobId": "job-1"} + return {"Status": "DONE", "ResultFile3Ds": [{"Type": "FBX", "Url": "https://c.test/r.fbx"}]} + + monkeypatch.setattr(tencent_mod, "call", fake_call) + monkeypatch.setattr(tencent_mod, "_download", lambda url, **_kw: FBX_BYTES) + + class _Uploader: + def upload(self, model: bytes, content_type: str) -> str: + return "https://cos.test/model.glb" + + autorig = tencent_mod.TencentAutoRigProvider( + _Uploader(), + tencent_mod.TencentCredentials("fake-id", "fake-key"), + allow_spend=True, + precheck=False, # 预检本身另有用例;这里测的是请求体 + ) + builder = Render3DAssetBuilder( + model3d=_FakeModel3D(), + autorig=autorig, + store=LocalDirAssetStore(tmp_path / "assets"), + review=_AutoApproveReview(), + may_build_assets=True, + ) + + data = builder.ensure(OUTFIT_KEY, b"master-png", _NullProgress()) + + submits = [params for action, params in sent if action == "SubmitAutoRiggingJob"] + assert len(submits) == 1 + assert submits[0].get("MotionType") == PRESET_MOTIONS[BUILD_MOTION].motion_type, ( + f"绑骨请求体里没有 MotionType(实际 {submits[0]!r})—— 接口会照样成功、照样扣 " + "10 积分,只是产物零动画,而帧数/时长/成色下游全部正常" + ) + assert data == FBX_BYTES + + +# ══ ② 零动画的产物不许当成就绪资产 ═════════════════════════════════════════════ + + +class _MuteAutoRig: + """回一个没有动作片段的绑骨产物 —— 真接口在缺 MotionType 时就是这样。""" + + def __init__(self) -> None: + self.calls = 0 + + def rig(self, model: bytes, *, want: str = "GLB", motion=None) -> RiggedModel: + self.calls += 1 + return RiggedModel(data=FBX_BYTES, fmt="FBX", motion=None) + + +def test_rigged_model_without_animation_never_becomes_a_ready_asset(tmp_path: pathlib.Path): + """绑骨产物里没有动作片段就抛,且**不落点**。 + + 拦的坏例:哑模型被存下来 → ``state()`` 变 READY → ``model_3d_url`` 回写到造型上 → + 之后每个动作任务都被路由到三渲二,而出帧台一个片段都找不到。10 积分买了一个 + 永远出不了帧的资产,且没有一处报错指向绑骨。 + """ + store = LocalDirAssetStore(tmp_path / "assets") + builder = Render3DAssetBuilder( + model3d=_FakeModel3D(), + autorig=_MuteAutoRig(), + store=store, + review=_AutoApproveReview(), + may_build_assets=True, + ) + + with pytest.raises(RuntimeError, match="没有动作片段"): + builder.ensure(OUTFIT_KEY, b"master-png", _NullProgress()) + + assert store.get(OUTFIT_KEY) is None + assert builder.state(OUTFIT_KEY) is Render3DAssetState.AWAITING_REVIEW, ( + "哑模型不该把造型推进 READY —— 图生 3D 的产物还在待审位上,重试只需再付绑骨" + ) + + +# ══ ③ 发出去的 URL 后缀要说真话 ═══════════════════════════════════════════════ + + +@pytest.mark.parametrize( + ("data", "suffix", "content_type"), + [ + (FBX_BYTES, ".fbx", "application/x-fbx"), + (GLB_BYTES, ".glb", "model/gltf-binary"), + ], +) +def test_published_model_url_keeps_the_real_container_format( + monkeypatch, data: bytes, suffix: str, content_type: str +): + """绑骨产物按 magic bytes 定后缀,不写死 ``.glb``。 + + 拦的坏例:绑骨接口即便被要求 GLB 也返回 FBX(归档里每一份绑骨产物都是 FBX),而 + ``_publish_model`` 恒用 ``rigged.glb`` 当文件名 → 对象键以 ``.glb`` 结尾 → 出帧台 + 按后缀挑到 GLTFLoader → "Bad glTF: json error"。症状看起来像出帧台坏了, + 而钱在两步之前就花完了。 + """ + from windup_app.server.media import service as media_service_mod + from windup_app.server.orchestrator.render3d_service import _publish_model + + seen: list[object] = [] + + class _Result: + url = "https://cdn.test/media/model-3d/deadbeef" + + class _Media: + def upload(self, payload: bytes, metadata): + seen.append(metadata) + return _Result() + + monkeypatch.setattr(media_service_mod, "service", _Media()) + _publish_model(data) + + assert seen[0].filename.endswith(suffix) + assert seen[0].content_type == content_type + + +# ══ ④ 资产没烘的动作不许派单 ═════════════════════════════════════════════════ + + +def _executor() -> ActionTaskExecutor: + from windup_ai_engine.impl import CharacterGenerator + from windup_ai_engine.strategy.concrete import RenderFrameStrategy + + return ActionTaskExecutor( + generator=CharacterGenerator({GenRoute.RENDER_3D: RenderFrameStrategy(None, directions=4)}), + upload=lambda _png: pytest.fail("还没出帧就上传了"), + fetch_master=lambda _input: pytest.fail("走三渲二不该去下载母版"), + fetch_model3d=lambda url: pytest.fail("出帧交给浏览器了,应用机不该再下模型"), + fetch_constraints=lambda *_: ProjectConstraints(sprite_w=64, sprite_h=64), + ) + + +def _input(action: InputActionType) -> CharacterActionInput: + return CharacterActionInput( + character_id=1, + action_type=action, + num_frames=4, + outfit_id="outfit-default", + model_3d_url="https://cdn.test/media/model-3d/rigged.fbx", + ) + + +@pytest.fixture +def _no_dispatch(monkeypatch): + """把出帧登记换成内存桩,顺便证明"被拒的动作一次都没登记过"。""" + jobs: dict[int, object] = {} + monkeypatch.setattr( + "windup_app.server.orchestrator.client_bake.open_job", + lambda task_id, spec: jobs.setdefault(task_id, spec) and 0.0, + ) + monkeypatch.setattr("windup_app.server.orchestrator.executor.is_own_media", lambda url: True) + return jobs + + +def test_action_the_asset_never_baked_is_refused_before_dispatch(_no_dispatch): + """资产只烘了 walk,attack 任务在**派单之前**被拒,而不是渲出一段走路。 + + 拦的坏例:模型里那唯一一个片段(名字是绑骨任务的哈希)照样渲得满 32 张帧,于是 + 攻击动作交付的是一段走路 —— 帧数、时长、朝向、成色全部自洽,没有一道会红。 + """ + with pytest.raises(ValueError, match="只烘了"): + _executor()._produce_action( + _input(InputActionType.ATTACK), + ProjectConstraints(sprite_w=64, sprite_h=64), + task_id=21, + ) + assert not _no_dispatch, "被拒的动作居然还登记了出帧任务" + + +def test_the_baked_action_still_gets_dispatched(_no_dispatch): + """反向对照:资产烘的那个动作照常派单 —— 上一条拒的是动作,不是整条路线。""" + with pytest.raises(ActionAwaitingClientBake): + _executor()._produce_action( + _input(InputActionType.WALK), + ProjectConstraints(sprite_w=64, sprite_h=64), + task_id=22, + ) + assert 22 in _no_dispatch + + +# ══ 映射表本身 ═══════════════════════════════════════════════════════════════ + + +def test_action_motion_table_covers_every_entry_action_and_names_only_real_presets(): + """入口枚举的每个动作都要在表里有明确条目,且映射到的名字必须是 provider 认识的。 + + 拦的两个坏例: + · 新增一个 ActionType 却忘了在表里表态 —— 它会被当成"不支持",这是安全的方向, + 但表要显式写出来,否则读表的人以为漏了; + · 映射到一个 PRESET_MOTIONS 里不存在的名字(打错字、或凭文档猜一个编号名)—— + ``resolve_motion`` 会抛 KeyError,而那一刻图生 3D 的 20 积分已经花完、 + 模型也已经过了人工确认。 + """ + assert set(ACTION_MOTIONS) == {a.value for a in InputActionType} + for action, motion in ACTION_MOTIONS.items(): + if motion is not None: + assert motion in PRESET_MOTIONS, f"{action} 映射到未登记的预设 {motion!r}" + assert BUILD_MOTION in PRESET_MOTIONS + assert RENDERABLE_ACTIONS, "一个动作都出不了的路线等于没有路线" diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index c39edf36d..fd4e25ab3 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -97,16 +97,22 @@ def image_to_3d(self, master, *, want="GLB", extra_views=None) -> bytes: class _FakeAutoRig: + """**不传 MotionType 就回零动画产物** —— 真接口就是这个行为(受理、扣费、产物没有 + AnimationStack)。替身恒回一个动作的话,"绑骨时忘了带动作"在这里永远看不出来。""" + def __init__(self) -> None: self.calls = 0 + self.motions: list[str | int | None] = [] @property def preset_motions(self): - return {"walk": PresetMotion(name="walk", motion_type=1)} + return {"walk": PresetMotion(name="walk", motion_type=23)} def rig(self, model, *, want="GLB", motion=None) -> RiggedModel: self.calls += 1 - return RiggedModel(data=b"RIGGED-bytes", fmt="GLB") + self.motions.append(motion) + preset = self.preset_motions.get(motion) if isinstance(motion, str) else None + return RiggedModel(data=b"RIGGED-bytes", fmt="GLB", motion=preset) def _sheet(directions: tuple[str, ...], n_frames: int) -> SpriteSheet: diff --git a/frontend/src/features/client-bake/index.test.ts b/frontend/src/features/client-bake/index.test.ts index 4c0c104f8..5d0886980 100644 --- a/frontend/src/features/client-bake/index.test.ts +++ b/frontend/src/features/client-bake/index.test.ts @@ -122,6 +122,26 @@ describe('浏览器出帧驱动', () => { expect(failed).toContain('idle') }) + it('片段名是绑骨任务哈希、对不上动作名时,用模型里唯一那一个', async () => { + // 拦的坏例:片段名由绑骨接口的动作库自己起(实测两次任务拿到的都是 + // `Armature|32795ddb244644eac67ccfd8b84060c3_remap`),永远等不上产品动作名 'walk'。 + // 按名字硬匹配的话**每一份真实绑骨产物**都会被判成"模型里没有片段 walk", + // 三渲二一帧都出不来 —— 而这条报错听上去像模型坏了。 + const HASHED = 'Armature|32795ddb244644eac67ccfd8b84060c3_remap' + stage.clips = { [HASHED]: 1.0667 } + let completed: { clip: string; sampleTimes: number[] } | null = null + const apis = stubRender3DApis({ + completeBake: async (_taskId, completion) => { + completed = completion + }, + }) + await runClientBake({ job: bakeJob({ frames: 2 }), apis }) + + expect(stage.setups.map(([clip]) => clip)).toEqual([HASHED, HASHED]) + // 交回的仍是**登记的那个名字** —— 后端按它对账,换成真实片段名会被判成交错了片段。 + expect(completed).toEqual({ clip: 'walk', sampleTimes: [0, 0.1] }) + }) + it('一个片段都没有时说清是绑骨没带动作', async () => { stage.clips = {} await expect(runClientBake({ job: bakeJob(), apis: stubRender3DApis() })).rejects.toThrow( diff --git a/frontend/src/features/client-bake/index.ts b/frontend/src/features/client-bake/index.ts index dd560e7bc..0ee4beb62 100644 --- a/frontend/src/features/client-bake/index.ts +++ b/frontend/src/features/client-bake/index.ts @@ -7,11 +7,11 @@ * **判定不跟着搬。** 帧数对账、空帧自检、脚线对齐、成色闸仍在服务端做 —— 这里做的 * 自检只是"早点炸",不是替服务端把关。客户端自报的数只是它的说法。 */ -import { BakeStage, StageError } from './stage' +import { BakeStage, StageError, resolveClip } from './stage' import type { BakeJob, Render3DApis } from '@/entities/render3d' -export { BakeStage, StageError, MATERIALS, isStageMaterial } from './stage' +export { BakeStage, StageError, MATERIALS, isStageMaterial, resolveClip } from './stage' export type { StageMaterial, StageRigInfo } from './stage' export interface BakeProgress { @@ -60,17 +60,14 @@ export async function runClientBake(options: RunClientBakeOptions): Promise