Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 资产处在哪一步。**状态由落点推出来,不单独存一份** ——
Expand Down Expand Up @@ -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}"

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions backend/packages/common/src/windup_common/enums/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
12 changes: 9 additions & 3 deletions backend/tests/test_render3d_asset_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading