feat(render3d): 前端接回 3D 资产创建入口 - #771
Conversation
母版卡片上给出建 / 审 / 弃三个动作,金额与「只收一次」都从后端 cost 读。 体型由用户声明(几何上判不出来),非双足在花钱之前拒;每账号最多同时持有 两个 3D 角色,弃掉即释放名额。 Closes #518
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## main #771 +/- ##
==========================================
+ Coverage 82.66% 87.77% +5.10%
==========================================
Files 102 281 +179
Lines 9162 20535 +11373
Branches 3034 3061 +27
==========================================
+ Hits 7574 18024 +10450
- Misses 759 1673 +914
- Partials 829 838 +9
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 173 files with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Reviewed the backend asset lifecycle/quota guards and the workflow-editor entry point. The stance gate and request wiring are consistent, but the new quota and failure UI paths leave users able to exceed the stated two-asset limit and unable to recover from some failed builds. Backend pytest could not be run because uv is unavailable; the frontend test command could not start because the local vitest binary is not executable.
Additional findings
frontend/src/pages/workflow-editor/render3d-panel.tsx:?: [P1] Make the failed-build reset action actually recover: The new failed-state button callsdiscardOutfitAsset, but the backend discard path only accepts assets whose builder state isAWAITING_REVIEW. If the initial image-to-3D call fails before a raw model is stored, the service reportsfailedwhile the builder state remainsABSENT; clicking the displayed清掉重来action therefore returns another 400 instead of clearing the failed job, so the user cannot retry that outfit through this panel. Add a reset path that also clears an initial-build failure (or hide/use a retry action that handles that state) before exposing this button forfailed.
三条都属于"任务照常 COMPLETED、帧数时长成色全对、零报错,只是产出的东西是错的"。
两位审查者(FennoAI + 内部审查)独立指出了其中两条。
① **位移轨恒为 None。** bake_driver.mjs 交回 meta 时已按片段名拆过一层
(root_motion: rootMotion[clip] ?? null),sprite.py 原样透传 —— _root_motion_of
再 .get(clip) 一次恒得 None。服务端渲那条 100% 走这里。
顺带修脏数据守卫:原来只校验 x 且解包先于守卫,长度不对照样 ValueError、z 是脏值
直接抛;守卫命中时又只丢那一条,让位移轨比帧数短、索引静默错位。改成整条作废。
② **并发串味。** ActionTaskExecutor 是进程级单例,strategy 被所有任务线程共用而
action 并发默认 8:A 的 derive 写完、A 的 _finish 读之前,B 的 derive 会覆盖它。
改用 ContextVar 按请求隔离,取完即清。不改 derive 的返回形状 —— 那是两个入口共用的。
③ **落库丢字段。** rig_facts / root_motion 只写进结果 JSON,没进 CharacterActionOutput
也没进 _deserialize_result:查询接口与断线重连拿到的已完成任务缺这两样,而实时事件
那条路有,两条路给出不同结果。同一个坑 geometry 已经踩过一次,注释就在它旁边。
配套修正测试:那个桩用的是 {"walk": {...}},比生产多包一层 —— ① 那个真 bug 就是这么
合进来的。改成 bake_driver 实际交回的形状。
另外 rebase 到 main(#771 已合)时解冲突,顺手清掉两处重复的 return 与一处把 strategy
当 render_spec 位置实参传的写法。
* feat(render3d): 出帧台读到的骨架事实与位移轨落进造型与动作 此前每渲一段都算一遍、算完即丢。骨架事实每造型一份,位移轨每动作一份, 取根骨动画轨而非交付帧像素反推。服务端渲与浏览器渲两条路交回同一批数。 Closes #774 * fix(render3d): 整改 #777 —— 位移轨恒空、并发串味、落库丢字段 三条都属于"任务照常 COMPLETED、帧数时长成色全对、零报错,只是产出的东西是错的"。 两位审查者(FennoAI + 内部审查)独立指出了其中两条。 ① **位移轨恒为 None。** bake_driver.mjs 交回 meta 时已按片段名拆过一层 (root_motion: rootMotion[clip] ?? null),sprite.py 原样透传 —— _root_motion_of 再 .get(clip) 一次恒得 None。服务端渲那条 100% 走这里。 顺带修脏数据守卫:原来只校验 x 且解包先于守卫,长度不对照样 ValueError、z 是脏值 直接抛;守卫命中时又只丢那一条,让位移轨比帧数短、索引静默错位。改成整条作废。 ② **并发串味。** ActionTaskExecutor 是进程级单例,strategy 被所有任务线程共用而 action 并发默认 8:A 的 derive 写完、A 的 _finish 读之前,B 的 derive 会覆盖它。 改用 ContextVar 按请求隔离,取完即清。不改 derive 的返回形状 —— 那是两个入口共用的。 ③ **落库丢字段。** rig_facts / root_motion 只写进结果 JSON,没进 CharacterActionOutput 也没进 _deserialize_result:查询接口与断线重连拿到的已完成任务缺这两样,而实时事件 那条路有,两条路给出不同结果。同一个坑 geometry 已经踩过一次,注释就在它旁边。 配套修正测试:那个桩用的是 {"walk": {...}},比生产多包一层 —— ① 那个真 bug 就是这么 合进来的。改成 bake_driver 实际交回的形状。 另外 rebase 到 main(#771 已合)时解冲突,顺手清掉两处重复的 return 与一处把 strategy 当 render_spec 位置实参传的写法。
Summary
母版卡片上接回 3D 资产入口:
absent给建、awaiting_review给放行/弃掉、building/rigging给在跑的反馈、failed展示后端文案。面板抽成render3d-panel.tsx(index.tsx已经 1900 行,且抽出来才能单独挂起来看渲染结果)。后端加两道闸,都在花钱之前:体型不可绑骨、每账号资产数上限。
三条约束的落法
金额只从后端
cost读。 建按钮上的 30 积分、确认里的 20+10 拆分、per_outfit_once的说明,全部来自GET的返回。前端 grep 不到硬编码的积分数——档位会变,抄一份就会分叉。awaiting_review是人工闸。 只有「放行绑骨」和「弃掉重建」两个动作,没有任何自动放行路径。文案写明模型生成即最终、改不动,以及放行会再扣绑骨那 10 积分。error原样展示。 不在前端重拼文案。体型闸:为什么是声明而不是检测
自动绑骨对非双足不报错——它漏认被遮挡的肢体,那条肢体每帧同姿势,而
motion_scale、死帧数、loop_seam、帧数时长成色全部正常,一道闸都不会红。用户拿到「有条腿是柱子」的动画且不知情。原本想从包围盒判。量了全部 25 份归档 GLB:
四足与人形完全重叠,且人形原始产物比狼更「长」——管线不同阶段的模型量纲不一样,几何上判不出来。所以
stance由调用方声明,且必填无默认:给默认值等于把「没声明」当成「双足」放行,而「没声明」最可能出现在非双足角色上。user_message()原本把这条脱敏成通用的「生成没能完成,请稍后重试」。这条不能抹——重试一万次也不会变,四足永远绑不了;原文里只有体型与路线名,无内部信息,已加进白名单原样透出。每账号上限
MAX_ASSETS_PER_USER = 2。数的是当前持有而不是历史建过多少次,弃掉一个释放一个名额:混元的模型生成即最终、不合格只能弃掉重建,名额若不释放,两个坏模型会把用户永久卡死在这条路线外面。建造中的不计入。那需要逐造型去问资产存储,而并发建多个的代价本来就由积分挡着。
顺带修的一处
not_a_pose超上限时的文案原本写「多半是模型躺着或轴向约定不对」。实测拦下带武器模型的正是这条闸(臂展/身高 4.10),而has_accessory一次都没触发——它靠 node/mesh 名里的武器词,真实产物的部件名是node_0/Material.001。原文案把用户往轴向上引,改成把手持物列在首位。一处既有测试的断言方向被改了
身份母版只展示现有操作里有expect(screen.queryByText(/3D 资产/)).toBeNull(),是 #486 撤掉入口时锁进去的。#486 的理由是 worker 镜像缺 node/playwright、点进去必然抛错;依赖在 #517 补齐、出帧又在 #717 改到浏览器之后,那条理由不成立。断言改成要求该分组存在,并在代码里注明了原委。不包含
Test plan
uv run ruff check ./export_openapi无漂移 /uv run lint-imports/uv run pytest -q:1773 passed, 14 skippedformat:check/lint/typecheck/test:coverage/build:1213 passed, 81 files两侧命令都跑在最后一次编辑之后。
Closes #518