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:
@@ -1,10 +1,10 @@
|
||||
import { lazy, Suspense, useState, type FormEvent } from 'react';
|
||||
import { errorMessage } from '../api/client';
|
||||
import { deleteModel, listModels, setPrimaryModel, updateModel, uploadModel } from '../api/endpoints';
|
||||
import type { Model, ModelUpdate, PublishStatus } from '../api/types';
|
||||
import type { Model, ModelFormat, ModelUpdate, PublishStatus } from '../api/types';
|
||||
import { MODEL_FORMATS } from '../api/types';
|
||||
import { formatBytes, formatDateTime } from '../lib/format';
|
||||
import { MODEL_FORMAT_LABELS, STATUS_OPTIONS } from '../lib/labels';
|
||||
import { formatBytes, formatCount, formatDateTime } from '../lib/format';
|
||||
import { MODEL_FORMAT_LABELS, STATUS_OPTIONS, drivableSummary, type DrivableSummary } from '../lib/labels';
|
||||
import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import {
|
||||
@@ -32,6 +32,16 @@ const VrmPreview = lazy(async () => {
|
||||
const PAGE_SIZE = 20;
|
||||
const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx,.zip';
|
||||
|
||||
/** 能用 GLTFLoader 预览的格式:GLB / glTF 与 VRM 同属 glTF 家族,仅 FBX 不支持。 */
|
||||
const PREVIEWABLE_FORMATS: ReadonlyArray<ModelFormat> = ['vrm', 'glb', 'gltf'];
|
||||
|
||||
/** 可驱动性 → 徽标配色(绿 / 黄 / 红)。 */
|
||||
const DRIVABLE_BADGE: Record<DrivableSummary['kind'], string> = {
|
||||
ok: 'badge-published',
|
||||
warn: 'badge-pending',
|
||||
error: 'badge-rejected',
|
||||
};
|
||||
|
||||
interface ModelFilters {
|
||||
q: string;
|
||||
format: string;
|
||||
@@ -344,8 +354,8 @@ export function ModelsPage() {
|
||||
};
|
||||
|
||||
const previewUnsupported =
|
||||
previewModel && previewModel.format === 'fbx'
|
||||
? 'FBX 无法用 GLTFLoader 预览(契约中 FBX 仅归档)。'
|
||||
previewModel && !PREVIEWABLE_FORMATS.includes(previewModel.format)
|
||||
? `${MODEL_FORMAT_LABELS[previewModel.format]} 无法用 GLTFLoader 预览(契约中 FBX 仅归档,请先转换为 GLB/VRM)。`
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -459,17 +469,18 @@ export function ModelsPage() {
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="thumb"
|
||||
className="thumb-link"
|
||||
onClick={() => setPreviewId(model.id)}
|
||||
title="点击预览"
|
||||
>
|
||||
{model.thumbnailUrl ? (
|
||||
<img src={model.thumbnailUrl} alt={`${model.name} 缩略图`} />
|
||||
<img
|
||||
className="thumb-sm"
|
||||
src={model.thumbnailUrl}
|
||||
alt={`${model.name} 缩略图`}
|
||||
/>
|
||||
) : (
|
||||
<span className="thumb-placeholder">
|
||||
{MODEL_FORMAT_LABELS[model.format]}
|
||||
<em>无缩略图</em>
|
||||
</span>
|
||||
<FormatBadge format={model.format} kind="model" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
@@ -557,9 +568,10 @@ export function ModelsPage() {
|
||||
</header>
|
||||
<div className="panel-body">
|
||||
<p className="muted small">
|
||||
通过 GLTFLoader + VRMLoaderPlugin 加载 <code>fileUrl</code>,VRM 0.x 自动执行 180° 旋转;
|
||||
通过 GLTFLoader + VRMLoaderPlugin 加载 <code>fileUrl</code>:VRM / GLB / glTF 同属 glTF
|
||||
家族,都走这条管线(非 VRM 的 GLB 按普通 glTF 渲染,VRM 0.x 自动执行 180° 旋转);
|
||||
使用 <code>requestAnimationFrame</code> 循环调用 <code>vrm.update(delta)</code>,
|
||||
卸载时释放渲染器、几何体、材质与贴图。
|
||||
卸载时释放渲染器、几何体、材质与贴图。FBX 不在预览范围内。
|
||||
</p>
|
||||
{previewModel ? (
|
||||
<ul className="kv-list">
|
||||
@@ -577,6 +589,35 @@ export function ModelsPage() {
|
||||
<span className="muted">是否默认</span>
|
||||
<span>{previewModel.isPrimary ? '是' : '否'}</span>
|
||||
</li>
|
||||
{previewModel.stats ? (
|
||||
<>
|
||||
<li>
|
||||
<span className="muted">面数 / 顶点数</span>
|
||||
<span className="mono">
|
||||
{formatCount(previewModel.stats.triangles)} 面 ·{' '}
|
||||
{formatCount(previewModel.stats.vertices)} 点
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="muted">骨骼 / 节点</span>
|
||||
<span className="mono">
|
||||
{formatCount(previewModel.stats.bones)} 根 ·{' '}
|
||||
{formatCount(previewModel.stats.nodes)} 节点
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="muted">可驱动性</span>
|
||||
<span className={`badge ${DRIVABLE_BADGE[drivableSummary(previewModel.stats).kind]}`}>
|
||||
{drivableSummary(previewModel.stats).text}
|
||||
</span>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<li>
|
||||
<span className="muted">分析数据</span>
|
||||
<span className="muted">stats 为空(旧数据或后端尚未分析)</span>
|
||||
</li>
|
||||
)}
|
||||
{previewModel.conversionNote ? (
|
||||
<li>
|
||||
<span className="muted">转换来源</span>
|
||||
|
||||
Reference in New Issue
Block a user