需求 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 变为该模型
236 lines
6.4 KiB
TypeScript
236 lines
6.4 KiB
TypeScript
/** 契约端点封装:页面只调用这些函数,不直接拼 URL。 */
|
|
|
|
import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client';
|
|
import type {
|
|
ContractVersion,
|
|
Health,
|
|
Model,
|
|
ModelPage,
|
|
ModelUpdate,
|
|
Motion,
|
|
MotionPage,
|
|
MotionUpdate,
|
|
PendingPage,
|
|
PublishStatus,
|
|
ReviewActionBody,
|
|
ReviewPage,
|
|
Song,
|
|
SongCreate,
|
|
SongPage,
|
|
SongUpdate,
|
|
TargetType,
|
|
} from './types';
|
|
|
|
/* ------------------------------------------------------------------ 列表 */
|
|
|
|
export interface ListParams {
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
export function listSongs(
|
|
params: ListParams & { q?: string; status?: PublishStatus | ''; artist?: string },
|
|
): Promise<SongPage> {
|
|
return apiGet<SongPage>('/api/songs', {
|
|
page: params.page,
|
|
pageSize: params.pageSize,
|
|
q: params.q,
|
|
status: params.status,
|
|
artist: params.artist,
|
|
});
|
|
}
|
|
|
|
export function listMotions(
|
|
params: ListParams & {
|
|
q?: string;
|
|
songId?: number | '';
|
|
format?: string;
|
|
status?: PublishStatus | '';
|
|
},
|
|
): Promise<MotionPage> {
|
|
return apiGet<MotionPage>('/api/motions', {
|
|
page: params.page,
|
|
pageSize: params.pageSize,
|
|
q: params.q,
|
|
songId: params.songId,
|
|
format: params.format,
|
|
status: params.status,
|
|
});
|
|
}
|
|
|
|
export function listModels(
|
|
params: ListParams & { q?: string; format?: string; status?: PublishStatus | '' },
|
|
): Promise<ModelPage> {
|
|
return apiGet<ModelPage>('/api/models', {
|
|
page: params.page,
|
|
pageSize: params.pageSize,
|
|
q: params.q,
|
|
format: params.format,
|
|
status: params.status,
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ 曲目 */
|
|
|
|
export function createSong(payload: SongCreate): Promise<Song> {
|
|
return apiPost<Song>('/api/songs', payload);
|
|
}
|
|
|
|
export function updateSong(id: number, payload: SongUpdate): Promise<Song> {
|
|
return apiPatch<Song>(`/api/songs/${id}`, payload);
|
|
}
|
|
|
|
export function deleteSong(id: number): Promise<void> {
|
|
return apiDelete(`/api/songs/${id}`);
|
|
}
|
|
|
|
/** multipart 字段名固定为 file。 */
|
|
export function uploadSongAudio(id: number, file: File): Promise<Song> {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
return apiUpload<Song>(`/api/songs/${id}/audio`, form);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ 动作 */
|
|
|
|
export interface MotionUploadInput {
|
|
file: File;
|
|
name: string;
|
|
songId?: number;
|
|
fps?: number;
|
|
autoPreprocess: boolean;
|
|
status: PublishStatus;
|
|
sourceType: string;
|
|
sourceName: string;
|
|
sourceUrl: string;
|
|
license: string;
|
|
author: string;
|
|
}
|
|
|
|
export function uploadMotion(input: MotionUploadInput): Promise<Motion> {
|
|
const form = new FormData();
|
|
form.append('file', input.file);
|
|
form.append('name', input.name);
|
|
if (input.songId !== undefined) form.append('songId', String(input.songId));
|
|
if (input.fps !== undefined) form.append('fps', String(input.fps));
|
|
form.append('autoPreprocess', input.autoPreprocess ? 'true' : 'false');
|
|
form.append('status', input.status);
|
|
form.append('sourceType', input.sourceType);
|
|
form.append('sourceName', input.sourceName);
|
|
form.append('sourceUrl', input.sourceUrl);
|
|
form.append('license', input.license);
|
|
form.append('author', input.author);
|
|
return apiUpload<Motion>('/api/motions', form);
|
|
}
|
|
|
|
export function updateMotion(id: number, payload: MotionUpdate): Promise<Motion> {
|
|
return apiPatch<Motion>(`/api/motions/${id}`, payload);
|
|
}
|
|
|
|
export function deleteMotion(id: number): Promise<void> {
|
|
return apiDelete(`/api/motions/${id}`);
|
|
}
|
|
|
|
export function preprocessMotion(id: number): Promise<Motion> {
|
|
return apiPost<Motion>(`/api/motions/${id}/preprocess`, {});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ 模型 */
|
|
|
|
export interface ModelUploadInput {
|
|
file: File;
|
|
name: string;
|
|
isPrimary: boolean;
|
|
status: PublishStatus;
|
|
sourceType: string;
|
|
sourceName: string;
|
|
sourceUrl: string;
|
|
license: string;
|
|
author: string;
|
|
}
|
|
|
|
export function uploadModel(input: ModelUploadInput): Promise<Model> {
|
|
const form = new FormData();
|
|
form.append('file', input.file);
|
|
form.append('name', input.name);
|
|
form.append('isPrimary', input.isPrimary ? 'true' : 'false');
|
|
form.append('status', input.status);
|
|
form.append('sourceType', input.sourceType);
|
|
form.append('sourceName', input.sourceName);
|
|
form.append('sourceUrl', input.sourceUrl);
|
|
form.append('license', input.license);
|
|
form.append('author', input.author);
|
|
return apiUpload<Model>('/api/models', form);
|
|
}
|
|
|
|
export function updateModel(id: number, payload: ModelUpdate): Promise<Model> {
|
|
return apiPatch<Model>(`/api/models/${id}`, payload);
|
|
}
|
|
|
|
/** GET /api/models/:id —— 审核面板用它拉详情(stats 可能为 null,旧数据未分析)。 */
|
|
export function getModel(id: number): Promise<Model> {
|
|
return apiGet<Model>(`/api/models/${id}`);
|
|
}
|
|
|
|
export function deleteModel(id: number): Promise<void> {
|
|
return apiDelete(`/api/models/${id}`);
|
|
}
|
|
|
|
export function setPrimaryModel(id: number): Promise<Model> {
|
|
return apiPost<Model>(`/api/models/${id}/primary`, {});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ 审核 */
|
|
|
|
export function listPending(params: ListParams & { status: PublishStatus }): Promise<PendingPage> {
|
|
return apiGet<PendingPage>('/api/reviews', {
|
|
status: params.status,
|
|
page: params.page,
|
|
pageSize: params.pageSize,
|
|
});
|
|
}
|
|
|
|
export function listReviewRecords(
|
|
params: ListParams & { targetType?: TargetType | '' },
|
|
): Promise<ReviewPage> {
|
|
return apiGet<ReviewPage>('/api/reviews', {
|
|
targetType: params.targetType,
|
|
page: params.page,
|
|
pageSize: params.pageSize,
|
|
});
|
|
}
|
|
|
|
export function reviewApprove(
|
|
targetType: TargetType,
|
|
id: number,
|
|
body: ReviewActionBody,
|
|
): Promise<void> {
|
|
return apiPost<void>(`/api/reviews/${targetType}/${id}/approve`, body);
|
|
}
|
|
|
|
export function reviewReject(
|
|
targetType: TargetType,
|
|
id: number,
|
|
body: ReviewActionBody,
|
|
): Promise<void> {
|
|
return apiPost<void>(`/api/reviews/${targetType}/${id}/reject`, body);
|
|
}
|
|
|
|
export function reviewSubmit(
|
|
targetType: TargetType,
|
|
id: number,
|
|
body: ReviewActionBody,
|
|
): Promise<void> {
|
|
return apiPost<void>(`/api/reviews/${targetType}/${id}/submit`, body);
|
|
}
|
|
|
|
/* -------------------------------------------------------------- 目录/健康 */
|
|
|
|
export function getHealth(): Promise<Health> {
|
|
return apiGet<Health>('/health');
|
|
}
|
|
|
|
export function getCatalogVersion(): Promise<ContractVersion> {
|
|
return apiGet<ContractVersion>('/api/catalog/version');
|
|
}
|