feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览

需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
This commit is contained in:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+310
View File
@@ -1175,3 +1175,313 @@ textarea {
font-size: 11px;
color: var(--text-dim);
}
/* ==========================================================================
模型审核(T-WEB-005 追加):待审列表 + 右侧模型审核面板
========================================================================== */
.review-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 620px);
gap: 14px;
align-items: start;
}
@media (max-width: 1440px) {
.review-layout {
grid-template-columns: minmax(0, 1fr);
}
}
.row-clickable {
cursor: pointer;
}
.check-field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
.check-field input[type='checkbox'] {
width: auto;
height: 16px;
margin: 0;
flex: 0 0 auto;
}
.panel-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
padding: 10px 16px;
border-top: 1px solid var(--border);
}
.panel-foot-actions {
display: flex;
gap: 8px;
}
/* -------------------------------------------------- 列表小缩略图 */
.thumb-link {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.thumb-sm {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg);
object-fit: cover;
display: block;
}
/* -------------------------------------------------- 审核面板骨架 */
.model-review {
position: sticky;
top: 16px;
display: flex;
flex-direction: column;
}
.model-review .panel-body {
gap: 14px;
max-height: 76vh;
overflow-y: auto;
}
.model-review-top {
display: flex;
gap: 12px;
align-items: flex-start;
}
.model-review-thumb {
width: 116px;
height: 116px;
flex: 0 0 116px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg);
object-fit: cover;
display: block;
}
.model-review-thumb-empty {
display: grid;
place-items: center;
text-align: center;
color: var(--text-mute);
font-size: 12px;
}
.model-review-basic {
flex: 1;
min-width: 0;
}
/* -------------------------------------------------- 关键指标卡片 */
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 8px;
}
.metric {
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.metric-strong {
border-color: rgba(76, 154, 255, 0.55);
background: rgba(76, 154, 255, 0.08);
}
.metric-label {
font-size: 11px;
color: var(--text-mute);
}
.metric-value {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
word-break: break-word;
}
.metric-hint {
font-size: 11px;
color: var(--text-mute);
}
.banner-ok {
background: rgba(63, 185, 80, 0.12);
border-color: rgba(63, 185, 80, 0.5);
color: #8fe0a0;
}
.banner-danger {
background: rgba(248, 81, 73, 0.12);
border-color: rgba(248, 81, 73, 0.5);
color: #ffb3ae;
}
/* -------------------------------------------------- humanoid 覆盖 */
.coverage {
display: flex;
flex-direction: column;
gap: 6px;
}
.coverage-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.progress {
height: 8px;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent-strong), var(--accent));
transition: width 0.2s ease;
}
.humanoid-names {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 120px;
overflow-y: auto;
}
.badge-humanoid {
border-color: rgba(163, 113, 247, 0.6);
color: #cbb2ff;
background: rgba(163, 113, 247, 0.12);
font-family: var(--mono);
}
.warn-list {
margin: 0;
padding-left: 18px;
color: #e8c26a;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* -------------------------------------------------- 骨骼树 */
.bone-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.bone-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.bone-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.bone-toolbar input[type='search'] {
flex: 1 1 200px;
min-width: 160px;
}
.bone-stats {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
font-size: 12px;
color: var(--text-dim);
}
.bone-stats strong {
font-family: var(--mono);
color: var(--text);
}
.bone-tree {
max-height: 60vh;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
}
.bone-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
min-height: 22px;
font-size: 12px;
white-space: nowrap;
border-bottom: 1px solid rgba(38, 46, 57, 0.6);
}
.bone-row:last-child {
border-bottom: none;
}
.bone-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.bone-row .badge {
padding: 0 6px;
line-height: 16px;
font-size: 10px;
}
.bone-name {
color: var(--text);
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
}
.bone-children {
margin-left: auto;
}