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
@@ -0,0 +1,373 @@
/**
* 模型审核面板(T-WEB-005 追加)。
*
* 待审列表只给 PendingItem(不含模型详情),所以这里用 item.targetId 现拉
* `GET /api/models/:id`。骨骼是后期游玩评分的基础,因此骨骼树是本面板的重点:
* 扁平数组 + depth 缩进渲染,支持按名搜索(保留祖先链)、仅看蒙皮骨骼、
* 限高滚动 + 只渲染前 N 行,千根骨骼也不会卡住页面。
*/
import { useMemo, useState, type ReactNode } from 'react';
import { getModel } from '../api/endpoints';
import type { Model, ModelStats, PendingItem } from '../api/types';
import { filterBoneTree, boneTreeStats } from '../lib/bone-tree';
import { formatBytes, formatCount } from '../lib/format';
import { MODEL_CONTAINER_LABELS, drivableSummary, type DrivableSummary } from '../lib/labels';
import { useAsync } from '../lib/useAsync';
import { ErrorBox, FormatBadge, Loading, Notice, StatusBadge } from './ui';
/** 骨骼树一次最多渲染的行数,超出部分提示「还有 X 根未显示」。 */
const BONE_ROW_LIMIT = 400;
/** 每层缩进像素。 */
const BONE_INDENT_PX = 14;
const BANNER_CLASS: Record<DrivableSummary['kind'], string> = {
ok: 'banner-ok',
warn: 'banner-warn',
error: 'banner-danger',
};
const BANNER_ICON: Record<DrivableSummary['kind'], string> = {
ok: '✅',
warn: '⚠',
error: '⛔',
};
/* ------------------------------------------------------------ 指标卡片 */
function Metric({
label,
value,
hint,
strong = false,
}: {
label: string;
value: ReactNode;
hint?: string;
strong?: boolean;
}) {
return (
<div className={`metric${strong ? ' metric-strong' : ''}`}>
<span className="metric-label">{label}</span>
<span className="metric-value">{value}</span>
{hint ? <span className="metric-hint">{hint}</span> : null}
</div>
);
}
/* ------------------------------------------------------------ 骨骼树 */
function BoneTreeSection({ stats }: { stats: ModelStats }) {
const [keyword, setKeyword] = useState('');
const [onlySkin, setOnlySkin] = useState(false);
const filtered = useMemo(
() => filterBoneTree(stats.boneTree, keyword, onlySkin),
[stats.boneTree, keyword, onlySkin],
);
const summary = useMemo(() => boneTreeStats(stats.boneTree), [stats.boneTree]);
// 大数据量保护:限高滚动之外,再只渲染前 N 行。
const shown = filtered.slice(0, BONE_ROW_LIMIT);
const hidden = filtered.length - shown.length;
return (
<section className="bone-section">
<header className="bone-head">
<h3>骨骼树</h3>
<span className="muted small">先序遍历,父在前、子在后,按层级缩进</span>
</header>
<div className="bone-toolbar">
<input
type="search"
placeholder="按骨骼名搜索(命中节点会保留祖先链)"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<label className="check-field">
<input
type="checkbox"
checked={onlySkin}
onChange={(event) => setOnlySkin(event.target.checked)}
/>
<span>仅显示蒙皮骨骼</span>
</label>
</div>
<div className="bone-stats">
<span>
总数 <strong>{formatCount(summary.total)}</strong>
</span>
<span>
蒙皮 <strong>{formatCount(summary.skin)}</strong>
</span>
<span>
已映射 humanoid <strong>{formatCount(summary.mapped)}</strong>
</span>
<span>
最大层级 <strong>{formatCount(summary.maxDepth)}</strong>
</span>
{keyword.trim() !== '' ? (
<span>
命中 <strong>{formatCount(filtered.length)}</strong>
</span>
) : null}
</div>
{filtered.length === 0 ? (
<p className="muted small">没有匹配的骨骼。</p>
) : (
<div className="bone-tree" role="tree" aria-label="骨骼树">
{shown.map((node, position) => (
<div
className="bone-row"
key={`${position}-${node.index}-${node.name}`}
role="treeitem"
aria-level={node.depth + 1}
style={{ paddingLeft: 8 + Math.max(0, node.depth) * BONE_INDENT_PX }}
>
<span className="bone-name mono">{node.name || '(无名节点)'}</span>
<span className={`badge ${node.inSkin ? 'badge-published' : 'badge-draft'}`}>
{node.inSkin ? '蒙皮' : '未使用'}
</span>
{node.humanoid ? (
<span className="badge badge-humanoid">{node.humanoid}</span>
) : (
<span className="badge">未映射</span>
)}
<span className="bone-children muted small">子 {node.childCount}</span>
</div>
))}
</div>
)}
{hidden > 0 ? (
<p className="muted small">
还有 {formatCount(hidden)} 根未显示(共 {formatCount(filtered.length)} 行,仅渲染前{' '}
{formatCount(BONE_ROW_LIMIT)} 行);可用搜索或「仅显示蒙皮骨骼」缩小范围。
</p>
) : null}
{onlySkin ? (
<p className="muted small">
已开启「仅显示蒙皮骨骼」:命中骨骼的祖先链会一并保留,因此个别「未使用」节点可能作为祖先出现。
</p>
) : null}
</section>
);
}
/* ------------------------------------------------ 分析结果(stats 区) */
function ModelStatsView({ model, stats }: { model: Model; stats: ModelStats }) {
const drivable = drivableSummary(stats);
const coverage =
stats.humanoidTotal > 0
? Math.min(100, Math.max(0, Math.round((stats.humanoidMapped / stats.humanoidTotal) * 100)))
: 0;
const humanoidNames = useMemo(() => {
const names = new Set<string>();
for (const bone of stats.boneTree) {
if (bone.humanoid !== null && bone.humanoid !== '') names.add(bone.humanoid);
}
return [...names].sort();
}, [stats.boneTree]);
const warnings = stats.warnings ?? [];
return (
<>
{/* 关键指标 */}
<div className="metric-grid">
<Metric label="转换后体积" value={formatBytes(stats.fileSize)} hint={`fileSize ${formatCount(stats.fileSize)} B`} />
<Metric label="格式 format" value={<FormatBadge format={model.format} kind="model" />} />
<Metric
label="容器 container"
value={stats.container}
hint={MODEL_CONTAINER_LABELS[stats.container]}
/>
<Metric label="VRM 版本" value={stats.vrmVersion ?? model.vrmVersion ?? '—'} />
<Metric label="面数 triangles" value={formatCount(stats.triangles)} />
<Metric label="顶点数 vertices" value={formatCount(stats.vertices)} />
<Metric label="网格数 meshes" value={formatCount(stats.meshes)} />
<Metric label="材质数 materials" value={formatCount(stats.materials)} />
<Metric label="贴图数 textures" value={formatCount(stats.textures)} />
<Metric label="骨骼数 bones" value={formatCount(stats.bones)} strong />
<Metric label="节点数 nodes" value={formatCount(stats.nodes)} />
<Metric label="形态目标 morphTargets" value={formatCount(stats.morphTargets)} />
<Metric label="动画数 animations" value={formatCount(stats.animations)} />
<Metric label="图元 primitives" value={formatCount(stats.primitives)} />
<Metric label="蒙皮 skins" value={formatCount(stats.skins)} />
<Metric label="骨骼最大层级" value={formatCount(stats.maxBoneDepth)} />
</div>
{/* 可驱动性结论 */}
<div className={`banner ${BANNER_CLASS[drivable.kind]}`} role={drivable.kind === 'error' ? 'alert' : 'status'}>
<strong>
{BANNER_ICON[drivable.kind]} {drivable.text}
</strong>
{drivable.kind === 'ok' ? (
<span className="muted">
humanoid 已映射 {formatCount(stats.humanoidMapped)}/{formatCount(stats.humanoidTotal)} 根,
骨骼树 {formatCount(stats.bones)} 根,最大层级 {formatCount(stats.maxBoneDepth)}。
</span>
) : null}
{drivable.kind === 'error' ? (
<span className="muted">驱动客户端 33 个关键点需要 16 根必需骨骼全部映射,缺失会导致无法计分。</span>
) : null}
{drivable.kind === 'warn' ? (
<span className="muted">该文件不是 glTF 容器(glb/gltf/vrm),只能审核来源与体积,无法核对骨骼。</span>
) : null}
</div>
{/* humanoid 覆盖 */}
<section className="coverage">
<header className="coverage-head">
<h3>humanoid 覆盖</h3>
<span className="mono small">
{formatCount(stats.humanoidMapped)} / {formatCount(stats.humanoidTotal)}({coverage}%)
</span>
</header>
<div className="progress" role="progressbar" aria-valuenow={coverage} aria-valuemin={0} aria-valuemax={100}>
<div className="progress-bar" style={{ width: `${coverage}%` }} />
</div>
{humanoidNames.length > 0 ? (
<div className="humanoid-names">
{humanoidNames.map((name) => (
<span className="badge badge-humanoid" key={name}>
{name}
</span>
))}
</div>
) : (
<p className="muted small">没有任何骨骼映射到 humanoid 规范名。</p>
)}
</section>
{/* warnings */}
{warnings.length > 0 ? (
<ul className="warn-list">
{warnings.map((warning, index) => (
<li key={`${index}-${warning}`}>{warning}</li>
))}
</ul>
) : null}
{/* 骨骼详细界面 */}
<BoneTreeSection stats={stats} />
</>
);
}
/* ---------------------------------------------------------------- 面板 */
export interface ModelReviewPanelProps {
item: PendingItem;
onClose: () => void;
/** 点「通过」时回调;setPrimary 为勾选的「同时设为全局默认模型」。 */
onApprove: (setPrimary: boolean) => void;
onReject: () => void;
}
export function ModelReviewPanel({ item, onClose, onApprove, onReject }: ModelReviewPanelProps) {
const state = useAsync(() => getModel(item.targetId), [item.targetId]);
const [setPrimary, setSetPrimary] = useState(false);
const [thumbFailed, setThumbFailed] = useState(false);
const model = state.data;
const stats = model?.stats ?? null;
const thumbnail = model?.thumbnailUrl && !thumbFailed ? model.thumbnailUrl : null;
return (
<section className="panel model-review">
<header className="panel-head">
<div>
<h2>模型审核 · {item.name}</h2>
<div className="muted small">
#{item.targetId} · GET /api/models/{item.targetId}
</div>
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭模型审核面板">
×
</button>
</header>
<div className="panel-body">
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
{state.loading && !model ? <Loading text="正在加载模型详情…" /> : null}
{model ? (
<>
<div className="model-review-top">
{thumbnail ? (
<img
className="model-review-thumb"
src={thumbnail}
alt={`${model.name} 缩略图`}
onError={() => setThumbFailed(true)}
/>
) : (
<div className="model-review-thumb model-review-thumb-empty">暂无预览图</div>
)}
<ul className="kv-list model-review-basic">
<li>
<span className="muted">格式</span>
<span>
<FormatBadge format={model.format} kind="model" />
</span>
</li>
<li>
<span className="muted">状态</span>
<span>
<StatusBadge status={model.status} />
</span>
</li>
<li>
<span className="muted">文件地址</span>
<span className="break">{model.fileUrl ?? '—'}</span>
</li>
{model.conversionNote ? (
<li>
<span className="muted">转换说明</span>
<span className="break">{model.conversionNote}</span>
</li>
) : null}
</ul>
</div>
{stats ? (
<ModelStatsView model={model} stats={stats} />
) : (
<Notice kind="info">
该模型暂无分析数据(stats 为空,可能是旧数据或后端尚未完成分析),只能依据格式、体积与来源判断,
无法核对骨骼与可驱动性。
</Notice>
)}
</>
) : null}
</div>
<footer className="panel-foot">
<label className="check-field">
<input
type="checkbox"
checked={setPrimary}
disabled={!model}
onChange={(event) => setSetPrimary(event.target.checked)}
/>
<span>同时设为全局默认模型</span>
</label>
<div className="panel-foot-actions">
<button type="button" className="btn btn-danger" onClick={onReject} disabled={!model}>
驳回
</button>
<button type="button" className="btn btn-primary" onClick={() => onApprove(setPrimary)} disabled={!model}>
通过
</button>
</div>
</footer>
</section>
);
}