diff --git a/backend/packages/app/src/windup_app/server/orchestrator/_failure.py b/backend/packages/app/src/windup_app/server/orchestrator/_failure.py index 55150b798..3a185879c 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/_failure.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/_failure.py @@ -39,6 +39,10 @@ def user_message(exc: BaseException) -> str: return f"动作描述没通过检查:{detail}" if detail else "动作描述没通过检查,换一种说法再试。" if name == "FetchNotAllowed": return "参考图地址不被接受,请重新上传图片后再试。" + if name == "StanceNotRiggable": + # 体型不在绑骨能力范围内。这条**不能抹成通用文案**:通用那句让用户去重试, + # 而重试一万次也不会变 —— 四足永远绑不了。原文里只有体型和路线名,无内部信息。 + return str(exc) if name == "QualityBlocked": # 判官给的是产品级原因,不是基础设施信息 —— 翻成人话交给用户, # 抹成"没通过检查"会让用户不知道改什么,那是脱敏脱过头。 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 042128f6c..b3feb3b28 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -662,7 +662,7 @@ def _fail_client(s: Session) -> None: start_from_model=_resolve_video_model(input.video_model), ) card, action, canvas = self._action_spec(input, cons) - progress: ProgressPort = _LogProgress() + progress: ProgressPort = _TaskProgress(task_id=task_id, project_id=project_id) generated = self._get_generator( _resolve_video_model(input.video_model), cons.directions ).finish_rendered(frames, card, action, progress, canvas=canvas) 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 5d405e110..e6dda8d8d 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 @@ -29,6 +29,7 @@ from windup_ai_engine.ports import MasterRejected from windup_app.server.orchestrator._fetch import FetchNotAllowed, fetch_own_media +from windup_common.models import CharacterStance from windup_app.server.orchestrator.render3d_assets import ( AUTORIG_CREDITS, BUILD_CREDITS, @@ -45,11 +46,19 @@ logger = logging.getLogger("windup.render3d.service") +# 拒绝文案里用得着的中文名。放这里而不是塞进枚举:枚举是给措辞门禁用的, +# 这几个字只服务于这一条拒绝理由。 +_STANCE_LABEL = { + CharacterStance.QUADRUPED: "四足", + CharacterStance.SERPENTINE: "无肢(蛇形)", +} + __all__ = [ "PHASE_BUILDING", "PHASE_FAILED", "PHASE_RIGGING", "FetchNotAllowed", "MasterPrecheckFailed", "Render3DAssetOperations", "SpendNotAuthorized", "default_operations", "precheck_master", "precheck_master_bytes", + "StanceNotRiggable", ] # 落点里存"绑骨模型的公网 URL"用的键前缀。与模型 bytes 同一个 store,是因为两者的 @@ -96,6 +105,19 @@ def precheck_master(master_url: str, canvas: tuple[int, int] | None = None) -> d return precheck_master_bytes(fetch_own_media(master_url), canvas) +class StanceNotRiggable(ValueError): + """体型不在自动绑骨的能力范围内 —— **在花钱之前**拒。 + + 为什么按声明拦而不是从模型几何判:实测拿全部归档 GLB 量过,四足与人形的包围盒比例 + 完全重叠(狼 Z/Y=1.47,而混元人形原始产物 3.19~4.47,比狼还大),因为管线不同阶段的 + 模型量纲不一样。几何上判不出来,只能让调用方声明。 + + 为什么必须拦而不是"建了再说":自动绑骨对非双足**不报错**,它会漏认被遮挡的肢体, + 那条肢体在每一帧保持同一姿势,而 ``motion_scale``、死帧数、``loop_seam``、帧数时长 + 成色**全部正常**,一道闸都不会红。用户拿到的是"有条腿是根柱子"的动画且不知情。 + """ + + class MasterPrecheckFailed(ValueError): """母版没过预检,拒绝为它花钱建 3D。``report`` 原样带给上层做文案。""" @@ -172,13 +194,21 @@ def view(self, outfit_key: str) -> dict: } # ── 三个动作 ───────────────────────────────────────────────────────── - def build(self, outfit_key: str, master_url: str) -> dict: - """① 图生 3D。**这一步开始花钱**,所以只在两个前提都成立时才起: - 该造型确实还什么都没有,且母版过得了零成本预检。 + def build(self, outfit_key: str, master_url: str, stance: CharacterStance) -> dict: + """① 图生 3D。**这一步开始花钱**,所以只在三个前提都成立时才起: + 该造型确实还什么都没有、体型能绑骨、且母版过得了零成本预检。 预检不过就在这里拒:母版不合格 → 模型必然不合格,而模型改不动只能重生成。 警告不拦 —— 它们已经在母版确认闸上给人看过,人点了"就用这张"就是他的决定。 + + ``stance`` **无默认值**:替调用方兜成双足的话,"没给"与"明确给了双足"从这里起 + 就分不开,而分不开的代价是四足角色照样被放行去绑骨(见 :class:`StanceNotRiggable`)。 """ + if stance is not CharacterStance.BIPED: + raise StanceNotRiggable( + f"{_STANCE_LABEL.get(stance, stance.value)}角色目前无法绑定骨骼,三渲二这条" + "路线只支持双足人形。这一步没有扣费,可以改走视频路线。" + ) if not self._builder.may_build_assets: raise SpendNotAuthorized( f"本部署未开启建 3D 资产(需 WINDUP_RENDER3D_ALLOW_SPEND)。建一次 " @@ -376,8 +406,8 @@ def _ops(self) -> Render3DAssetOperations: 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 build(self, outfit_key: str, master_url: str, stance: CharacterStance) -> dict: + return self._ops().build(outfit_key, master_url, stance) def approve(self, outfit_key: str, master_url: str) -> dict: return self._ops().approve(outfit_key, master_url) diff --git a/backend/packages/app/src/windup_app/web/api/render3d.py b/backend/packages/app/src/windup_app/web/api/render3d.py index fd866110f..635964157 100644 --- a/backend/packages/app/src/windup_app/web/api/render3d.py +++ b/backend/packages/app/src/windup_app/web/api/render3d.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, File, Request, UploadFile from pydantic import BaseModel, Field +from sqlalchemy import select from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode @@ -21,6 +22,8 @@ from windup_framework.db import get_session from windup_app.server.character.model import Character, CharacterData +from windup_app.server.project.model import Project +from windup_common.models import CharacterStance from windup_app.server.orchestrator import client_bake, task_repo from windup_app.server.orchestrator._failure import user_message from windup_app.server.character.service import service as character_service @@ -31,6 +34,16 @@ router = APIRouter(prefix="/render3d", tags=["render3d"]) +class BuildAssetRequest(BaseModel): + """建 3D 资产的入参。 + + ``stance`` 必填、无默认:自动绑骨只支持双足,而四足/无肢从模型几何判不出来 + (实测归档模型的包围盒比例完全重叠)。给默认值等于把「没声明」当成「双足」放行。 + """ + + stance: CharacterStance + + class MasterPrecheckRequest(BaseModel): """要预检的母版。只收自家对象存储的 URL —— 服务端替调用方拉任意地址等于把服务器 当跳板,见 ``orchestrator._fetch``。""" @@ -55,6 +68,36 @@ def _precheck(request: Request): return precheck +#: 每个用户最多同时持有的 3D 资产数。 +#: +#: 这条是**产品限额**不是技术限制:一个 3D 资产 = 图生 3D 20 积分 + 绑骨 10 积分, +#: 且后续动作全部复用它,所以它是这条路线上唯一的重成本点。 +MAX_ASSETS_PER_USER = 2 + + +def _owned_asset_count(session: Session, user_id: int) -> int: + """该用户名下已经建成的 3D 资产数。 + + 数的是**当前持有**而不是历史建过多少次 —— 弃掉一个就释放一个名额。理由是混元的 + 模型生成即最终、改不动,不合格只能弃掉重建;名额若不释放,两个坏模型就把用户永久 + 卡死在这条路线外面。 + + 只数已落 ``model_3d_url`` 的。建造中的不计入:那需要逐造型去问资产存储,而并发建多个 + 的代价本来就由积分挡着(每个都真金白银扣),不值得为它多打一圈存储。 + """ + rows = session.execute( + select(Character.character_data) + .join(Project, Character.project_id == Project.id) + .where(Project.user_id == user_id) + ).scalars().all() + owned = 0 + for data in rows: + for outfit in (data or {}).get("outfits", []): + if outfit.get("model_3d_url"): + owned += 1 + return owned + + def _asset_key(character_id: int, outfit_id: str) -> str: """3D 资产落点的键。**必须带上角色 id**:``outfit_id`` 只在所属角色内唯一, 而工作流给首个造型的 id 是写死的 ``outfit-default`` —— 只用它当键,全站每个角色 @@ -142,6 +185,7 @@ def get_outfit_asset( def build_outfit_asset( character_id: int, outfit_id: str, + body: BuildAssetRequest, request: Request, session: Session = Depends(get_session), ) -> Response[dict]: @@ -149,11 +193,18 @@ def build_outfit_asset( user_id = request.state.current_user.id character = get_character_with_auth(session, character_id, user_id) outfit = _outfit_or_raise(character, outfit_id) + owned = _owned_asset_count(session, user_id) + if owned >= MAX_ASSETS_PER_USER: + raise BizException( + f"每个账号最多同时持有 {MAX_ASSETS_PER_USER} 个 3D 角色,你已经有 {owned} 个。" + "弃掉其中一个就能再建。", + code=BizCode.BAD_REQUEST, + ) operations = _operations(request) try: return Response.success( operations.build(_asset_key(character_id, outfit_id), - _master_url_or_raise(outfit)), + _master_url_or_raise(outfit), body.stance), message="已开始生成 3D 模型", ) except ValueError as exc: diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/checks.py b/backend/packages/framework/src/windup_framework/providers/render3d/checks.py index 129a116f2..6ae911cf1 100644 --- a/backend/packages/framework/src/windup_framework/providers/render3d/checks.py +++ b/backend/packages/framework/src/windup_framework/providers/render3d/checks.py @@ -319,8 +319,10 @@ def check_model( if ratio > arm_span_max: raise ModelRejected( ModelRejectCode.NOT_A_POSE, - f"臂展/身高 = {ratio:.2f},高于上限 {arm_span_max};人形 A/T-Pose 不会这么宽 —— " - "多半是模型躺着或轴向约定不对(glTF 规范是 Y-up)。包围盒 " + f"臂展/身高 = {ratio:.2f},高于上限 {arm_span_max};人形 A/T-Pose 不会这么宽。" + "最常见的原因是**角色手里拿着东西**:剑、法杖、弓这类延展物会把包围盒横向" + "撑开,而它们本身也绑不了骨(会被当成肢体绑上权重、动画里乱甩)。" + "其次才是模型躺着或轴向约定不对(glTF 规范是 Y-up)。包围盒 " f"{facts.bbox[0]:.3f}×{facts.bbox[1]:.3f}×{facts.bbox[2]:.3f}。", ) return facts diff --git a/backend/tests/test_render3d_asset_endpoints.py b/backend/tests/test_render3d_asset_endpoints.py index c2ff1c010..8a3d315a5 100644 --- a/backend/tests/test_render3d_asset_endpoints.py +++ b/backend/tests/test_render3d_asset_endpoints.py @@ -26,6 +26,8 @@ from windup_app.server.project.model import Project MASTER_URL = "https://cdn.windup.test/media/reference-image/master.png" +# 建资产必填体型:自动绑骨只支持双足,且几何上判不出来,只能声明。 +BIPED = {"stance": "biped"} OUTFIT_ID = "outfit-default" @@ -180,7 +182,7 @@ def test_cost_numbers_come_from_the_billing_implementation(api): def test_build_stops_at_the_review_gate_without_rigging(api, render3d): """建完 ① 就停。**绑骨一次都不许调** —— 这道闸的全部价值就在这里。""" - data = _data(api.post(f"{_base()}/build")) + data = _data(api.post(f"{_base()}/build", json=BIPED)) assert data["state"] == "awaiting_review" assert render3d.test_model3d.calls == 1 assert render3d.test_autorig.calls == 0 @@ -190,7 +192,7 @@ def test_build_stops_at_the_review_gate_without_rigging(api, render3d): def test_waiting_at_the_gate_forever_never_auto_approves(api, render3d): """反复查状态不会把闸熬开。超时自动放行的闸等于没有闸,只是把"没人看"伪装成 "看过了"。""" - api.post(f"{_base()}/build") + api.post(f"{_base()}/build", json=BIPED) for _ in range(10): assert _data(api.get(_base()))["state"] == "awaiting_review" assert render3d.test_autorig.calls == 0 @@ -199,18 +201,18 @@ def test_waiting_at_the_gate_forever_never_auto_approves(api, render3d): def test_awaiting_review_hands_out_a_link_to_the_model(api): """待审模型必须能取到。只躺在服务器磁盘上的话,人点"通过"时其实一眼都没看到, 闸就退化成一个必须点的按钮 —— 比没有闸更糟,它制造了"已经审过"的假象。""" - data = _data(api.post(f"{_base()}/build")) + data = _data(api.post(f"{_base()}/build", json=BIPED)) 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") + api.post(f"{_base()}/build", json=BIPED) 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") + api.post(f"{_base()}/build", json=BIPED) data = _data(api.post(f"{_base()}/approve")) assert data["state"] == "ready" assert render3d.test_autorig.calls == 1 @@ -225,11 +227,11 @@ def test_approving_before_there_is_a_model_is_refused(api, render3d): def test_discard_sends_it_back_to_absent_and_the_next_build_regenerates(api, render3d): """不合格 → 丢弃 → 重新生成。混元的模型改不动,这是唯一的补救。""" - api.post(f"{_base()}/build") + api.post(f"{_base()}/build", json=BIPED) 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 _data(api.post(f"{_base()}/build", json=BIPED))["state"] == "awaiting_review" assert render3d.test_model3d.calls == 2 # 重新生成要再付一次图生 3D @@ -240,14 +242,14 @@ def _boom(model: bytes, *, want: str = "GLB", motion=None): render3d.test_autorig.calls += 1 raise RuntimeError("绑骨服务 500") - api.post(f"{_base()}/build") + api.post(f"{_base()}/build", json=BIPED) 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", ( + assert _data(api.post(f"{_base()}/build", json=BIPED))["state"] == "awaiting_review", ( "新模型被旧的批准标记放行了" ) @@ -256,15 +258,15 @@ def _boom(model: bytes, *, want: str = "GLB", motion=None): 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 + api.post(f"{_base()}/build", json=BIPED) + assert api.post(f"{_base()}/build", json=BIPED).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()}/build", json=BIPED) api.post(f"{_base()}/approve") - assert api.post(f"{_base()}/build").json()["code"] == 400 + assert api.post(f"{_base()}/build", json=BIPED).json()["code"] == 400 assert render3d.test_model3d.calls == 1 assert render3d.test_autorig.calls == 1 @@ -272,7 +274,7 @@ def test_ready_asset_is_not_rebuilt(api, render3d): 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()}/build", json=BIPED) api.post(f"{_base()}/approve") api.get(_base()) # 回写发生在读状态这一步 @@ -295,7 +297,7 @@ def test_unusable_master_is_refused_before_any_paid_call(api, render3d): blank.save(buf, "PNG") render3d.test_source["master"] = buf.getvalue() - assert api.post(f"{_base()}/build").json()["code"] == 400 + assert api.post(f"{_base()}/build", json=BIPED).json()["code"] == 400 assert render3d.test_model3d.calls == 0 @@ -303,7 +305,7 @@ 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 _data(api.post(f"{_base()}/build", json=BIPED))["state"] == "awaiting_review" assert render3d.test_model3d.calls == 1 @@ -355,7 +357,7 @@ def test_other_users_character_is_not_reachable(auth_client_b, api): def test_asset_key_is_namespaced_by_character(api, render3d, engine): """``outfit-default`` 是工作流写死给首个造型的 id。只用它当落点键的话, 全站每个角色的默认造型会共用同一个 3D 模型,且没有任何报错。""" - api.post(f"{_base()}/build") + api.post(f"{_base()}/build", json=BIPED) from sqlalchemy.orm import sessionmaker session = sessionmaker(bind=engine)() @@ -372,3 +374,102 @@ def test_asset_key_is_namespaced_by_character(api, render3d, engine): other = api.get(f"/render3d/characters/8/outfits/{OUTFIT_ID}") assert _data(other)["state"] == "absent", "另一个角色的默认造型不该套用这一个的模型" + + +# ── 体型闸:非双足不给建 ──────────────────────────────────────────────────── + + +def test_quadruped_is_refused_before_any_spend(api, render3d): + """四足角色在**花钱之前**就被拦下,且拒绝理由说得出改法。 + + 为什么必须拦而不是建了再说:自动绑骨对非双足**不报错**,它漏认被遮挡的肢体,那条 + 肢体每帧同姿势,而 motion_scale、死帧数、loop_seam、帧数时长成色全部正常 —— + 一道闸都不会红,用户拿到"有条腿是柱子"的动画且不知情。 + """ + body = api.post(f"{_base()}/build", json={"stance": "quadruped"}).json() + assert body["code"] == 400, body + assert "无法绑定骨骼" in body["message"] + assert render3d.test_model3d.calls == 0, "四足被拦下却已经调过图生 3D" + + +def test_serpentine_is_refused_too(api, render3d): + body = api.post(f"{_base()}/build", json={"stance": "serpentine"}).json() + assert body["code"] == 400, body + assert render3d.test_model3d.calls == 0 + + +def test_stance_has_no_default(api, render3d): + """不带 stance 直接拒。给默认值等于把「没声明」当成「双足」放行 —— 而这条链路上 + 「没声明」最可能出现在非双足角色上(调用方没意识到有这个字段)。""" + body = api.post(f"{_base()}/build", json={}).json() + assert body["code"] != 200, body + assert render3d.test_model3d.calls == 0 + + +def test_biped_still_builds(api, render3d): + """闸不能把正常路径也挡了 —— 控制样本。""" + data = _data(api.post(f"{_base()}/build", json=BIPED)) + assert data["state"] in {"building", "awaiting_review"} + assert render3d.test_model3d.calls == 1 + + +# ── 每用户限额 ──────────────────────────────────────────────────────────── + + +def _give_user_assets(engine, count: int) -> None: + """给 user 1 名下塞 count 个已建成的 3D 资产(另起角色,不碰被测那个造型)。""" + from sqlalchemy.orm import sessionmaker + + session = sessionmaker(bind=engine)() + for index in range(count): + session.add(Character( + id=100 + index, project_id=1, workflow_run_id=100 + index, name=f"已有{index}", + character_data={"version": 1, "outfits": [ + {"id": "outfit-default", "name": "常态", "description": None, + "preview_url": MASTER_URL, + "model_3d_url": f"https://cdn.windup.test/media/model-3d/{index}.glb", + "actions": []}, + ]}, + status=0, + )) + session.commit() + session.close() + + +def test_third_asset_is_refused(api, engine, render3d): + """名额用满就不给建,且**在花钱之前**拒。""" + _give_user_assets(engine, 2) + body = api.post(f"{_base()}/build", json=BIPED).json() + assert body["code"] == 400, body + assert "最多同时持有 2 个" in body["message"] + assert render3d.test_model3d.calls == 0, "名额满了却已经调过图生 3D" + + +def test_second_asset_still_allowed(api, engine, render3d): + """闸不能把正常路径挡了 —— 控制样本:已有 1 个时第 2 个必须能建。""" + _give_user_assets(engine, 1) + data = _data(api.post(f"{_base()}/build", json=BIPED)) + assert data["state"] in {"building", "awaiting_review"} + assert render3d.test_model3d.calls == 1 + + +def test_discarding_frees_a_slot(api, engine, render3d): + """弃掉一个要能再建。 + + 混元的模型生成即最终、改不动,不合格只能弃掉重建;名额若不释放,两个坏模型就把 + 用户永久卡死在这条路线外面。 + """ + _give_user_assets(engine, 2) + assert api.post(f"{_base()}/build", json=BIPED).json()["code"] == 400 + + from sqlalchemy.orm import sessionmaker + session = sessionmaker(bind=engine)() + stale = session.get(Character, 100) + data = dict(stale.character_data) + data["outfits"] = [{**o, "model_3d_url": None} for o in data["outfits"]] + stale.character_data = data + session.commit() + session.close() + + data = _data(api.post(f"{_base()}/build", json=BIPED)) + assert data["state"] in {"building", "awaiting_review"} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index b8a2878fe..efb5d874c 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -119,6 +119,7 @@ export { createRender3DApis, render3DApis, Render3DContractError } from './rende export type { BakeCompletion, BakeJob, + CharacterStance, MasterFacts, MasterPrecheckReport, MasterRejectCode, diff --git a/frontend/src/entities/render3d/api.test.ts b/frontend/src/entities/render3d/api.test.ts index 6cafd9075..90f8a4f4b 100644 --- a/frontend/src/entities/render3d/api.test.ts +++ b/frontend/src/entities/render3d/api.test.ts @@ -60,7 +60,7 @@ describe('三渲二资产适配器', () => { const { client, calls } = clientReturning(ASSET) const apis = createRender3DApis(client) await apis.getOutfitAsset('7', 'outfit-default') - await apis.buildOutfitAsset('7', 'outfit-default') + await apis.buildOutfitAsset('7', 'outfit-default', 'biped') await apis.approveOutfitAsset('7', 'outfit-default') await apis.discardOutfitAsset('7', 'outfit-default') @@ -220,7 +220,7 @@ describe('render3DApis 单例', () => { await render3DApis.precheckMaster('https://cdn.test/master.png') await render3DApis.getOutfitAsset('7', 'outfit-default') - await render3DApis.buildOutfitAsset('7', 'outfit-default') + await render3DApis.buildOutfitAsset('7', 'outfit-default', 'biped') await render3DApis.approveOutfitAsset('7', 'outfit-default') await render3DApis.discardOutfitAsset('7', 'outfit-default') diff --git a/frontend/src/entities/render3d/api.ts b/frontend/src/entities/render3d/api.ts index 9c4115698..0d4f03026 100644 --- a/frontend/src/entities/render3d/api.ts +++ b/frontend/src/entities/render3d/api.ts @@ -202,10 +202,11 @@ export function createRender3DApis(client: ApiClient = defaultClient()): Render3 return parseAsset(await client.request(outfitPath(characterId, outfitId))) }, - async buildOutfitAsset(characterId, outfitId) { + async buildOutfitAsset(characterId, outfitId, stance) { return parseAsset( await client.request(`${outfitPath(characterId, outfitId)}/build`, { method: 'POST', + json: { stance }, }), ) }, @@ -274,8 +275,8 @@ export const render3DApis: Render3DApis = { precheckMaster: (imageUrl, canvas) => createRender3DApis().precheckMaster(imageUrl, canvas), getOutfitAsset: (characterId, outfitId) => createRender3DApis().getOutfitAsset(characterId, outfitId), - buildOutfitAsset: (characterId, outfitId) => - createRender3DApis().buildOutfitAsset(characterId, outfitId), + buildOutfitAsset: (characterId, outfitId, stance) => + createRender3DApis().buildOutfitAsset(characterId, outfitId, stance), approveOutfitAsset: (characterId, outfitId) => createRender3DApis().approveOutfitAsset(characterId, outfitId), discardOutfitAsset: (characterId, outfitId) => diff --git a/frontend/src/entities/render3d/bake-api.test.ts b/frontend/src/entities/render3d/bake-api.test.ts index a7f127cac..8e6d8fa65 100644 --- a/frontend/src/entities/render3d/bake-api.test.ts +++ b/frontend/src/entities/render3d/bake-api.test.ts @@ -106,3 +106,25 @@ describe('出帧任务的网络边界', () => { expect(calls[0].json).toEqual({ reason: 'WebGL 起不来' }) }) }) + +describe('建 3D 资产的体型入参', () => { + it('把 stance 放进请求体 —— 后端必填,前端不能省', async () => { + const { api, calls } = client(() => ({ + state: 'building', + model_3d_url: null, + review_model_url: null, + error: null, + cost: { + model3d_credits: 20, + autorig_credits: 10, + total_credits: 30, + billing: 'postpaid', + scope: 'per_outfit_once', + }, + })) + await createRender3DApis(api).buildOutfitAsset('7', 'outfit-default', 'quadruped') + expect(calls[0].path).toBe('/render3d/characters/7/outfits/outfit-default/build') + expect(calls[0].method).toBe('POST') + expect(calls[0].json).toEqual({ stance: 'quadruped' }) + }) +}) diff --git a/frontend/src/entities/render3d/index.ts b/frontend/src/entities/render3d/index.ts index 9fd6b6db5..1b4e09890 100644 --- a/frontend/src/entities/render3d/index.ts +++ b/frontend/src/entities/render3d/index.ts @@ -109,6 +109,9 @@ export interface BakeCompletion { sampleTimes: number[] } +/** 角色体型。决定这条路线能不能给它绑骨;与后端 `CharacterStance` 同一取值域。 */ +export type CharacterStance = 'biped' | 'quadruped' | 'serpentine' + export interface Render3DApis { /** 零成本母版预检。**不触发任何按次计费调用**,确认闸上可以随便调。 */ precheckMaster( @@ -116,8 +119,17 @@ export interface Render3DApis { canvas?: { width: number; height: number }, ): Promise getOutfitAsset(characterId: string, outfitId: string): Promise - /** 触发图生 3D。**按次计费**,只能由用户的显式操作调用。 */ - buildOutfitAsset(characterId: string, outfitId: string): Promise + /** + * 触发图生 3D。**按次计费**,只能由用户的显式操作调用。 + * + * ``stance`` 必填、无默认:自动绑骨只支持双足,而四足/无肢从模型几何判不出来 + * (实测归档模型的包围盒比例完全重叠)。给默认值等于把"没声明"当成"双足"。 + */ + buildOutfitAsset( + characterId: string, + outfitId: string, + stance: CharacterStance, + ): Promise /** 人看过模型点头 → 继续绑骨。 */ approveOutfitAsset(characterId: string, outfitId: string): Promise /** 模型不合格 → 丢弃重来。 */ diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index ccd52e937..e465b9668 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -93,7 +93,7 @@ beforeEach(() => { afterEach(cleanup) describe('WorkflowEditorPage real runtime boundary', () => { - it('身份母版只展示现有操作,并让加号菜单专注于生成动作', async () => { + it('身份母版展示导出、调整与 3D 资产入口,加号菜单专注于生成动作', async () => { const character = characterFixture() character.outfits[0] = { ...character.outfits[0]!, @@ -112,7 +112,9 @@ describe('WorkflowEditorPage real runtime boundary', () => { }) expect(within(secondaryActions).getByRole('button', { name: '重新生成角色母版' })).toBeTruthy() expect(within(secondaryActions).getByRole('button', { name: '微调角色母版' })).toBeTruthy() - expect(screen.queryByText(/3D 资产/)).toBeNull() + // #486 当时把 3D 入口撤了,理由是 worker 镜像缺 node/playwright、点进去必然抛错; + // 依赖在 #517 补齐、出帧又在 #717 改到浏览器之后,那条理由不成立了(#518)。 + expect(await within(primaryActions).findByRole('group', { name: '3D 资产' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '添加动作分支' })) const actionMenu = screen.getByRole('menu', { name: '新增节点' }) diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index e368d9aa4..f69fda4cf 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -40,6 +40,7 @@ import { type ExportPackageModel, } from '@/features/export-package' import { FrameAnimationPlayer, GenerationPreviewCard, GenerationProgressCopy } from '@/shared/ui' +import { Render3DAssetPanel } from './render3d-panel' import { loadDefaultActionPresets, type WorkflowEditorSession } from './runtime' import { useWorkflowEditorSession } from './use-workflow-editor-session' import { WorkflowEditorView, type WorkflowCardNode } from './workflow-editor-view' @@ -837,6 +838,14 @@ function CharacterTemplateContent({ 身份已锁定
{outfit ? : null} + {outfit ? ( + + ) : null}
+ {blocked && precheck ?

{precheck.detail}

: null} + + ) : null} + + {asset.state === 'absent' && confirming ? ( +
+

+ 将扣 {cost.totalCredits} 积分(图生 3D {cost.model3dCredits} + 绑骨{' '} + {cost.autorigCredits})。 + {cost.scope === 'per_outfit_once' ? '同一造型只收一次,后续动作不再计费。' : null} + 每个账号最多同时持有 2 个 3D 角色,弃掉一个就能再建。 +

+
+ {/* 体型必须由用户声明:四足与人形的包围盒比例完全重叠,几何上判不出来, + 而自动绑骨对非双足不报错,只会漏认被遮挡的肢体。 */} + 这个角色是—— + {STANCE_CHOICES.map(({ value, label, hint }) => ( + + ))} +
+
+ + +
+ {stance !== 'biped' ? ( +

+ {STANCE_CHOICES.find((choice) => choice.value === stance)?.label} + 角色目前无法绑定骨骼,三渲二只支持双足人形。这一步没有扣费,可以改走视频路线。 +

+ ) : null} +
+ ) : null} + + {asset.state === 'awaiting_review' ? ( +
+ {asset.reviewModelUrl ? ( + + 下载模型看一眼 + + ) : null} +

+ 模型生成即最终、改不动;不合格只能弃掉重建。放行会再扣 {cost.autorigCredits} 积分绑骨。 +

+
+ + +
+
+ ) : null} + + {asset.state === 'failed' ? ( + + ) : null} +
+ ) +} diff --git a/openapi.json b/openapi.json index 935ddc709..21ca54223 100644 --- a/openapi.json +++ b/openapi.json @@ -200,6 +200,19 @@ "title": "Body_upload_media_media_upload_post", "type": "object" }, + "BuildAssetRequest": { + "description": "建 3D 资产的入参。\n\n``stance`` 必填、无默认:自动绑骨只支持双足,而四足/无肢从模型几何判不出来\n(实测归档模型的包围盒比例完全重叠)。给默认值等于把「没声明」当成「双足」放行。", + "properties": { + "stance": { + "$ref": "#/components/schemas/CharacterStance" + } + }, + "required": [ + "stance" + ], + "title": "BuildAssetRequest", + "type": "object" + }, "ChangePasswordRequest": { "description": "修改密码请求。", "properties": { @@ -5633,6 +5646,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildAssetRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": {