Skip to content
Merged
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 @@ -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":
# 判官给的是产品级原因,不是基础设施信息 —— 翻成人话交给用户,
# 抹成"没通过检查"会让用户不知道改什么,那是脱敏脱过头。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,是因为两者的
Expand Down Expand Up @@ -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`` 原样带给上层做文案。"""

Expand Down Expand Up @@ -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)。建一次 "
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 52 additions & 1 deletion backend/packages/app/src/windup_app/web/api/render3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -31,6 +34,16 @@
router = APIRouter(prefix="/render3d", tags=["render3d"])


class BuildAssetRequest(BaseModel):
"""建 3D 资产的入参。

``stance`` 必填、无默认:自动绑骨只支持双足,而四足/无肢从模型几何判不出来
(实测归档模型的包围盒比例完全重叠)。给默认值等于把「没声明」当成「双足」放行。
"""

stance: CharacterStance


class MasterPrecheckRequest(BaseModel):
"""要预检的母版。只收自家对象存储的 URL —— 服务端替调用方拉任意地址等于把服务器
当跳板,见 ``orchestrator._fetch``。"""
Expand All @@ -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"):
Comment thread
xiaocheny214 marked this conversation as resolved.
owned += 1
return owned


def _asset_key(character_id: int, outfit_id: str) -> str:
"""3D 资产落点的键。**必须带上角色 id**:``outfit_id`` 只在所属角色内唯一,
而工作流给首个造型的 id 是写死的 ``outfit-default`` —— 只用它当键,全站每个角色
Expand Down Expand Up @@ -142,18 +185,26 @@ 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]:
"""启动图生 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)
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading