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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user