Files
zijunchiang 12041f2e58 fix(upload,models): 上传落盘补齐兜底 + 不可用模型归因准确(v0.7.5)
两个真实缺陷,都由本轮的「跨端边界验收」暴露:

一、上传落盘:去重命中时删掉了唯一副本(T-WEB-017)
persistTempFile(动作 / 模型 / 场景上传走的通路)在 sha256 去重命中时直接
safeUnlink(tempPath) 并返回旧记录 —— 一旦记录指向的路径不存在(换过 STORAGE_DIR、
只恢复数据库、手工删过文件),上传仍返回 201,但后续动作预处理 500
(实测 ENOENT .../<sha>.vmd)、模型分析与缩略图静默失败。持久化通路 persistBuffer
早在上一轮就补了「文件缺失则按原路径补写」,这条通路漏了;现在同样处理:
先 existsSync(absolutePath(existing)),缺失则按记录里的原路径 rename(跨设备退化为
copyFile)补写,只有确认目标存在才删临时文件。
新增 test/storage-persist.spec.ts(5 项):首次上传 / 去重命中且文件在 /
去重命中但文件已丢失(补写而非删除)/ persistBuffer 回归 / 空文件拒绝。
反向验证:临时还原修复后第 3 条立刻失败,恢复后 5 项全过。

二、不可用模型的原因归因不准确(T-WEB-017)
unusableReason() 只要 stats 里有 humanoid 数字就归因于 humanoid,于是一个直接上传的
.glb(骨架完全合规、drivable=true、只是没有 VRM 容器)会被写成
「humanoid 不达标(19/22)」,把审核引向"重新绑定骨骼"而不是"以 VRM 交付"。
实测(跨端验收用真实 PMX→Assimp→GLB,886 KB):
  修复前 → humanoid 不达标(19/22),未产出 VRM
  修复后 → 未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标),需重新以 VRM 交付才能进入客户端目录
现在按 drivable 区分:不满足 16 根必需骨才归因 humanoid;达标但没 VRM 容器则明确说"已达标"。
新增 2 项单测(model-usability 5 → 7 项)。

三、跨端边界验收(Windows × Web)新增两类场景,双方都断言
  * 不可用模型:真实 PMX 用 Assimp 转 GLB 后入库(等价"转换只产出 GLB")→
    usable=false、原因准确、**不进入 catalog/export**、defaultModelId 不变;
    包内客户端的 bundle.models 里没有任何非 VRM 模型;
  * 场景 config 只给 {physics:{gravity:{y:-9.8}}} → 服务端补齐默认
    (碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条),
    客户端解析后逐项一致。

实测:npm run verify 全绿(契约 6 项 / 35 路由 / 版本 0.7.5 / 27 文件 309 项测试 /
build / 产物级实体注册核对 / 启动冒烟);跨端集成验收在**发布包起的服务端**上全部通过。
版本 0.7.4 → 0.7.5。
2026-09-18 03:03:50 +08:00

56 KiB

opendance-web

OpenDance Studio 阶段一: Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)。

管理歌曲、VRM 模型与动作来源,是 Windows 客户端与 Android 辅助 App 的唯一数据源与契约权威。

本服务端不部署、不管理 MySQL。 数据库为部署方提供的现有实例,连接信息通过 .env 注入。

本服务端也不部署反向代理。 只提供业务服务(API + 自带管理端页面),反代由另外的服务/团队负责,本仓库仅在 deploy/proxy/ 下提供配置说明文档。

状态

阶段一已完成: T-WEB-001 -> T-WEB-017 全部落地,当前版本 v0.7.5(契约 rest-api 0.5.0、websocket-keypoints 0.2.0、data-models 0.7.0、model-formats 0.5.0、skeleton-topology 0.2.0、database 0.4.0)。

v0.7.4 修订(场景功能的真正根因:Scene 没有注册进 ENTITIES):

  1. 根因(生产实测):/health 的 scenes.error 返回 No metadata for "Scene" was found. —— src/entities/index.ts 的 ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord] 漏了 Scene。 TypeORM 只按 ENTITIES 注册 metadata,于是:
    • 启动完全正常(DI 图可解析、/health 正常、migrationsPending 0),
    • 但第一次查 Scene 就抛No metadata,于是 /api/scenes、 /api/catalog/export、/api/catalog/version、/api/reviews?status=pending 全部 500 —— 客户端拉不到任何目录、管理端待审列表打不开;
    • 我此前"数据库缺 scenes 表"的判断也是错的:表其实是由 AddScenes 迁移建好的, 缺的是实体注册;
  2. 修复:ENTITIES 加入 Scene(并导出)。这也解释了为什么 v0.7.3 的降级修复是必要的 —— 即使根因是漏注册,降级也保证了"场景坏了不拖垮整站";
  3. 新增静态防线test/entities-registry.spec.ts(4 项):扫描 src/entities/*.entity.ts 的 @Entity() 类,逐一与 ENTITIES 对账 (漏一个即失败)、检查无重复/无多余,并核对 AddScenes 迁移文件确实存在。 已做反向验证:把 Scene 从 ENTITIES 移除,该测试立刻失败。 这类"漏注册"既过得了 tsc,也过得了启动冒烟(应用能启动), 只能靠静态核对兜住;
  4. web-server/scripts/diagnose-scenes.mjs 保持不变(默认只读),部署 v0.7.4 后运行它 可确认 scenes 表与列的实际情况。

v0.7.5 修订(不可用模型的原因归因必须准确):

  1. unusableReason() 原来只要 stats 里有 humanoid 数字就归因于 humanoid, 于是一个直接上传的 .glb(骨架完全合规、drivable = true、只是没有 VRM 容器) 会被写成「humanoid 不达标(19/22)」—— 审核界面据此会把修复方向引到"重新绑定骨骼", 而实际要做的是"以 VRM 交付";
  2. 现在按 drivable 区分:确实不满足 16 根必需骨 → 仍报 humanoid 覆盖度; 骨架已达标但没 VRM 容器 → 明确说明"已达标,需重新以 VRM 交付";
  3. 新增 2 项单测(5 → 7 项):「humanoid 已达标时不得出现『humanoid 不达标』字样」 与「drivable=false 时仍归因于 humanoid」;
  4. 上传落盘的对称兜底:persistTempFile(动作/模型/场景上传走的通路)在去重命中时 会直接删掉刚上传的唯一副本并返回旧记录 —— 若记录指向的路径已不存在 (换过 STORAGE_DIR、只恢复数据库、手工删过文件),上传仍返回 201, 但后续动作预处理 500(实测 ENOENT ... .vmd)、模型分析与缩略图静默失败。 persistBuffer 早有兜底,这条通路漏了;现按记录里的原路径补写。 新增 test/storage-persist.spec.ts(5 项)覆盖首次上传 / 去重命中 / 文件已丢失补写 / 内存通路回归 / 空文件拒绝,并做了反向验证(还原修复后该用例立刻失败);
  5. 跨端边界验收(Windows × Web)新增两类场景:不可用模型(真实 PMX 转 GLB 后入库) 不进入 catalog/export 且 defaultModelId 不变;场景 config 缺字段时服务端补齐默认值。

v0.7.4 之后的测试补强(不改产物,故不新发版本):

  1. 新增 web-server/test/real-samples.spec.ts(6 项,opt-in):用仓库根 示例文件/ 下的真实素材跑完整管线,补上"合成夹具证明不了真素材能出片"的缺口 ——
    • ① 模型→VRM:初音未来 19/22、奥黛塔 21/22 humanoid,drivable = true, 18 张贴图全内嵌、36 项双面材质补回,并逐字节校验 POSITION 访问器的 min/max 等于真实包围盒(glTF 规范唯一强制填写的边界,客户端据此剔除)、 蒙皮下标不越界;
    • ② 动作内部标准 + 骨骼重绑定:真实 VMD 31 条轨道、标准骨架 31/31 命中、 2901 帧产出 33+2 点与 9 个关节角,15 个直接关节置信度恒为 1.0(不退化成推算值);
    • 样例或 Assimp 缺失时整体跳过,CI 与无素材的开发机不受影响;
  2. 修 test/assimp-locate.spec.ts 的非密闭性:该文件的用例断言的是 PATH / 常见系统目录的查找行为,但会继承外部 ASSIMP_BIN (assimp-lib.sh 里 ASSIMP_BIN 优先级高于 PATH)。开发者按本项目文档 在 shell 里设过 ASSIMP_BIN 时 npm test 会假失败,现已在 runLib() 里剔除;
  3. 规模 25 文件 / 295 项(三个真实样例齐备并装好 Assimp 时的实测数)。

v0.7.3 修订(场景表不可用不再拖垮整站 —— 生产事故修复): 另有一个更直接的缺陷同时修掉:ScenesService.create() 当初按内存存储写, 判断的是 file.buffer,而上传中间件(storage.module.ts)用的是 multer 的 diskStorage —— 文件对象只有 path、没有 buffer,于是任何场景都传不上来, 一律 400「上传场景必须带 file 字段」。现在与 models/motions 走同一套: requireUploadedFile() 校验落盘并修正 latin1 文件名编码,直接导入走 persistTempFile()(顺带清理临时文件),需转换时 readFile(upload.path) 后再转, 并在 finally 中清掉临时文件;

  1. 现场:数据库里没有 scenes 表(迁移没落到该库 / 部署的 dist 里缺迁移文件),于是 GET /api/scenes、/api/catalog/export、/api/catalog/version、 /api/reviews?status=pending 全部 500,而 /api/songs、/api/models 正常。 后果是客户端拉不到任何目录(Windows/Android 都用不了)、管理端待审列表也打不开;
  2. 契约与文档都写明「场景是可选资源」(没有可用场景时客户端回退内置空场景),所以 场景表不可用不应该连带打挂与场景无关的功能。本次改为降级:
    • ScenesService 新增 queryScenes() 包装:数据库层面失败时返回空结果、 只记一次日志(避免刷屏),并把原因记在 diagnostics() 里;
    • 表恢复后自动回到可用状态(下一次成功查询即清除降级标记);
    • ReviewsService.listByStatus() 对场景查询单独 catch,待审列表照常返回其它资源;
    • /health 新增 scenes: { available, error } —— 降级但绝不静默;
  3. 新增诊断脚本 web-server/scripts/diagnose-scenes.mjs(默认只读): 打印运行期真实连的库、表清单、typeorm_migrations 记录、代码实际加载的迁移 (用于识别"迁移没被加载 → /health 显示 pending 0 但表不存在"这种最隐蔽的情况)、 以及 scenes 实际列 vs 实体期望列的逐列 diff;--fix 仅在缺表时用迁移里 同一份 DDL 幂等补表;
  4. 顺带修正 models.controller.ts 里一句误导性注释:模型上传不接受 isPrimary/status(契约:上传一律 pending,默认模型由审核通过时带 setPrimary=true 或 POST /api/models/:id/primary 设定) —— 冒烟脚本此前正是 按错误预期断言,已同步修正;
  5. 新增 6 项测试(test/scenes-degradation.spec.ts)锁定降级行为。
  6. 更正一次误判:排查过程中曾把 Gitea 附件接口的 HTTP 500 {"message":"","url":"…/api/swagger"} (空消息)判成"宿主机磁盘写满"。实际是上传写法错误 —— 该接口 multipart 字段名是 attachment,用 file= 就会得到这个空消息 500。实测对照:同一 1 KB 文件, -F "attachment=@…" → 201,--data-binary @… + ?name= → 201, -F "file=@…" → 500。两种正确写法已写进 deploy/proxy/README.md 第九节。 仍然真实存在的唯一限制是 IIS maxAllowedContentLength(默认 30,000,000 字节): 927 KB / 14 MB / 16.4 MB 通过,30.5 MB / 38.2 MB / 40 MB 一律 HTTP 413。

v0.7.2 修订(修复产物"装得上、起不来"+ /health 报错版本号):

  1. CatalogModule 漏 import ScenesModule(致命):CatalogService 注入了 ScenesService(因为 catalog/export 要下发 scenes[]),但 CatalogModule 只在 imports 里列了 SongsModule / MotionsModule / ModelsModule。Nest 的 DI 是按模块解析的,于是 node dist/main.js 在 bootstrap 阶段直接抛 UnknownDependenciesException —— v0.7.0 / v0.7.1 的产物根本起不来(装得上、起不来)。而当时 nest build 通过、258 项单元测试全绿、打包脚本的产物完整性校验也通过,因为它们都从不编译 AppModule;
  2. 新增启动冒烟 scripts/check-bootstrap.mjs(并挂进 verify 与打包脚本):以正式部署方式(ExecStart=node dist/main.js,不带任何 npm 环境变量、不需要数据库)拉起真实产物,断言①日志里没有依赖解析类错误(模块图可解析)②日志里的「应用版本 vX.Y.Z」等于 package.json 的 version ③启动确实推进到了数据库连接阶段。已做反向验证:把 ScenesModule 删掉后该检查立刻失败;
  3. /health 的版本号在原生部署下永远是 0.1.0:原来用 process.env.npm_package_version ?? '0.1.0',而该变量只在 npm 脚本下存在,正式部署是原生服务 → 为 undefined → 与管理端左下角展示的 vX.Y.Z 不一致,违反文档 §11.2 第 4 条「可核对」。新增 src/config/app-version.ts 作为运行时唯一解析点(APP_VERSION → 运行入口旁/工作目录的 package.json(带包名校验) → npm_package_version → 显式 0.0.0),main.ts 启动首行也打印版本号,便于与 /health、管理端核对;
  4. 新增 scripts/check-version.mjs(并入 contracts:check):校验 web-server/package.json = admin/package.json = project.yaml,禁止 health.controller.ts 再出现硬编码版本回退;管理端已构建时断言产物 JS 内含 vX.Y.Z;打包脚本在构建管理端后再次执行;
  5. 新增 12 项测试(test/app-version.spec.ts),其中一项显式模拟「无 npm 环境变量」的原生部署。

v0.7.1 修订(契约补齐 + 文档补齐,契约 websocket-keypoints 0.1.0 -> 0.2.0):

  1. 补齐 rest-api.yaml 里漏写的场景结构:catalog/export 自 v0.7.0 起就返回 scenes[],data-models.md 7 也已冻结,但 CatalogBundle 漏了这个字段、components 里也没有 Scene/SceneConfig。本次只补规格、不改线格式,故六个契约版本号不变(rest-api 仍 0.5.0);
  2. 契约 websocket-keypoints -> 0.2.0(T-WEB-016,与 Android T-AND-011 的"两端一起改"配套):新增 1.1 保活(接收端必须声明 silentTimeoutMs/heartbeatIntervalMs,且心跳 < 静默超时的一半)、3.1 hello.deviceId、3.2/3.3 welcome/config 携带保活字段、3.6 应用层 ping/pong、5 设备去重、7 关闭码与重连策略、7.1 退避与 ±20% 抖动、8 连接状态机(两端同名)。子协议名与二进制帧 version 均不变,向后兼容;
  3. deploy/proxy/README.md 新增第九节:托管 Gitea 的 IIS 站点附件上传上限(与一~五节的 Web 服务反代是两个不同站点)。实测 30.5 MB / 40 MB 返回 HTTP 413,根因是 IIS 默认 maxAllowedContentLength = 30,000,000 字节;给出放宽到 256 MB 的 IIS/ARR/Gitea app.ini/nginx 四处配置与复验命令 —— 这正是 Windows 端 v0.1.0 被拆成 zip.part0/1/2 的根因;
  4. 补齐 tasks/011 ~ tasks/016:此前 T-WEB-011~015 已在代码中落地但缺少任务说明书与 project.yaml 条目,本次一并补上(T-WEB-011 版本号展示 / 012 VRM 优先 / 013 动作标准与骨骼重绑定 / 014 动作预览 / 015 场景管理 / 016 契约 0.2.0);
  5. 规模 18 文件 / 246 项测试,contracts:check 通过、check-routes 35 条路由一致。

v0.7.0 修订(管理端场景页,补齐场景端到端可用):

  1. 管理端「场景管理」页(/scenes):列表(40px 缩略图 + 物理摘要「重力 -9.8 | 风 5.0m/s + 阵风 2.5m/s@0.5Hz」+ 状态)、上传表单(GLB 文件 + 名称 + 来源五元组 + 物理参数:重力、风基础向量、阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光)、详情面板(复用 3D 预览 + 参数可视化编辑并保存);
  2. 新增 physicsSummary() 纯函数与场景页样式(两栏自适应);路由 /scenes + 侧栏「场景管理」入口;
  3. 新增 2 项测试;规模 19 文件 / 245 项。

v0.6.0 修订(场景管理,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0):

  1. 新增场景管理(T-WEB-015):场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM);上传 .glb/.gltf 直接导入,.zip/.pmx/.fbx 由服务端转 GLB;
  2. 场景物理参数(SceneConfig 冻结在契约 data-models 7.1):重力向量、风向动态(基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源(auto/mesh/none)、环境光/雾、出生点(≤16)、相机;服务端只存参数、不跑物理(仿真在客户端 Godot);
  3. 入库即收敛:越界夹紧、非法枚举回退默认值,保证客户端拿到的参数一定可解释;scenes.config/stats 用 MEDIUMTEXT;
  4. 端点:GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}(共 35 条路由,check-routes 通过);审核复用 /api/reviews/scene/:id/approve|reject(上传一律 pending);catalog/export 增 scenes[] 且指纹纳入场景变更;
  5. 缩略图复用模型光栅化器 + 分析复用模型检查器;新增 6 项测试;规模 19 文件 / 243 项。

v0.5.0 修订(动作预览,契约 data-models -> 0.6.0):

  1. 动作骨架缩略图(服务端):新增 motions/motion-thumbnail.ts——把关键点序列画成正视图骨架 PNG(零原生依赖,复用抽出的 common/png.ts);随 /preprocess 生成并落库(motions.thumbnail_asset_id,迁移 AddMotionThumbnail),失败只记 warning 不影响预处理;
  2. 动作 3D 回放(管理端,重要):新增 MotionPreview——懒加载 GET /api/motions/:id/keypoints(NDJSON),骨架线框 + 关节球体,支持播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环,叠加"第 N/M 帧 · 当前时间 · 总时长",按整段包围盒自动取景;three.js 单独分包,主包体积不涨;
  3. 动作管理列表:新增 40px 骨架缩略图列(点击开预览)+「预览/收起预览」按钮;
  4. 关键点解析与拓扑(管理端 lib/keypoint-sequence.ts / lib/skeleton-topology.ts)纯函数化,便于测试;
  5. 新增 8 项测试;规模 18 文件 / 237 项。

v0.4.0 修订(骨骼重绑定:把"一律转 VRM"真正可用,契约 skeleton-topology -> 0.2.0):

  1. 修复 humanoid 覆盖率恒为 0/22 的根因:normalizeBoneName() 原先只保留 a-z0-9,中日文字符被整个丢掉(上半身/左ひじ/首 全变空串);现在保留 CJK/假名,并把全角 ASCII(足IK)折成半角;
  2. 候选表补齐四类命名:VRoid / Mixamo / MMD 标准骨架(下半身/上半身/上半身2/首/頭/左肩/左腕/左ひじ/左手首/左足/左ひざ/左足首/左つま先)/ 常见中文名;精确匹配保证 左足IK 不抢占 左足;
  3. 服务端与 Windows 客户端逐字一致:humanoid_map.gd 的 CANDIDATES 由服务端表生成写入,parity 测试断言;
  4. 实测:奥黛塔 PMX 0/22 → 21/22、初音未来 PMX 0/22 → 19/22,均 drivable=true 且产出 VRM;
  5. 契约 skeleton-topology -> 0.2.0:新增 §6「内部动作标准与模型骨骼重绑定」。

v0.3.0 修订(VRM 主力化,契约 data-models / model-formats -> 0.5.0):

  1. 所有上传的模型一律转 VRM:MODEL_CONVERT_FORMAT 默认改为 vrm —— .pmx/.fbx 经 assimp -> GLB -> VRM 1.0,glb/gltf 导入后包装为 VRM;支持库沿用自研 vrm-writer/vrm-inspector + Assimp(可选 @gltf-transform/core 做结构校验);
  2. humanoid 门槛与可用性标记(usable):必需骨齐全才产出 VRM;不达标(如 MMD 日文骨名 0/22)时回退交付 GLB、usable=false —— 可入库、可审核,但不进入 catalog/export(/api/catalog/version 指纹同步过滤);待骨骼重绑定后重新导入即转为可用;
  3. 模型列表与审核面板显示 「不可用」及其原因(unusableReason,例如 humanoid 不达标(0/22),未产出 VRM);resolveDefaultModelId 只在可用模型中选择;
  4. 管理端左下角展示当前版本号(v1.5 文档第十一章要求):构建期从 web-server/package.json 注入 v__APP_VERSION__,与 GET /health 的 version 同源;顺带修掉右下角写死的 契约 rest-api 0.1.0;
  5. 新增 5 项测试(test/model-usability.spec.ts);规模 16 文件 / 225 项。

v0.2.5 修订:

MMD 材质/着色对齐:PMX 的着色模型(卡通阶梯 + 球面贴图 + 平坦环境光)与 glTF PBR 不是一回事,Assimp 转换会丢东西。实测样本 【奥黛塔】_by_原神.zip(39 个材质):

  1. 修复"看起来颜色/贴图不对"的主因:PMX 里 36 个材质带"不剔除背面"标志,而 Assimp 导出的 GLB 里 doubleSided 一个都没有 -> 头发/裙摆/睫毛这类薄片被单面剔除。现在新增 pmx-materials.ts(只读 PMX 材质段)并在转换后按 drawFlag 补回 doubleSided;
  2. PBR 参数规范化(glb-materials.ts):metallicFactor=0、roughnessFactor=clamp(1-shininess/100, 0.5, 1)(MMD 常见 shininess=1 -> 0.99 哑光)、baseColorFactor 乘上 PMX diffuse(含 alpha);清掉 Assimp 带出的、与 MMD 无关的 KHR_materials_specular / volume / transmission / ior / emissive_strength;顺手修正 Assimp 写坏的 accessor.min/max(实测 min 被写成 2.2e-308,Godot 用它算包围盒);
  3. 新增 MODEL_SHADING(默认 pbr,可选 unlit):unlit 会给每个材质写 KHR_materials_unlit,颜色 = 贴图 x baseColorFactor、与场景光照无关,最接近 MMD 的平涂观感(three.js 与 Godot 都支持);
  4. 说明(能力边界):PMX 的球面贴图(该模型 15 项)与卡通 toon 阶梯在 glTF core 里没有对应槽位,任何转换器都无法原样保留;服务端会把数量写进 conversion_note(审核面板可见),不会假装保留;
  5. 新增 9 项测试(test/mmd-materials.spec.ts);测试规模 15 文件 / 220 项;
  6. 注意:材质修正在转换时生效,已有模型需重新导入(不是 reanalyze-models.mjs,它只刷新分析与缩略图)。

v0.2.4 修订:

  1. 修复缩略图"白模":缩略图渲染器原先刻意不解码贴图,只用 baseColorFactor 上色;而 MMD(PMX 转换)模型的 baseColorFactor 基本都是白色,于是列表页与审核面板里的缩略图全是白色模型。现在渲染器内置极简 PNG 解码器(zlib + 反滤波,8 位非隔行,colorType 0/2/3/4/6)并按 baseColorFactor x 贴图 逐像素采样,同时按 alphaMode(OPAQUE / MASK / BLEND)处理透明度;解码失败或非 PNG 时回退纯色,绝不抛错;
  2. 缩略图取景收紧(FRAMING 1.6 -> 1.3),40px 列表缩略图里主体更清楚;
  3. 新增 6 项贴图/透明度/朝向测试(test/model-thumbnail-texture.spec.ts);
  4. 说明:GLB 数据本身没问题——贴图逐字节内嵌(18/18)、39 个材质的贴图对应关系与 PMX 完全一致、UV 取到的就是皮肤/五官区域;白模只出在缩略图渲染器。

v0.2.3 修订:

  1. 修复上传 PMX 返回 500(models.stats 容量):分析结果里的骨骼树按骨骼逐条展开,实测一个 643 骨骼的 PMX(【奥黛塔】_by_原神.zip)就有 70,980 字节,而该列是 TEXT(上限 65,535),MySQL 严格模式直接拒绝写入(1406)导致 500。列迁移到 MEDIUMTEXT(AddModelStatsMediumText),并新增 shrinkStatsForStorage 兜底:即使库没迁移,也只截断骨骼树、保留面数/骨骼数/humanoid 覆盖/可驱动性,绝不让上传失败;
  2. 修复 GBK 压缩包丢贴图:文件名解码先试 Shift-JIS,而 WHATWG 的 shift_jis 会把未定义码位映射成私用区不可见字符(不是 U+FFFD),于是像 tex/髮.png(GBK 字节 F3 8C)这样的名字被判为「解码成功」而永不回退 GBK,贴图名对不上就丢图。现在私用区也算解错信号;实测该模型贴图由 17/18 变为 18/18 全部内嵌;
  3. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项)。

v0.2.2 修订:

  1. 修复 Assimp 缺失时的报错与部署体验:服务器没装 Assimp 时,上传 .pmx/.fbx(或含它们的压缩包)只得到两条 导出失败(ENOENT): spawn assimp ENOENT,看不出缺什么、怎么装;
  2. 转换前先定位 Assimp 可执行文件(src/common/assimp.ts):按 ASSIMP_BIN -> PATH -> /usr/bin、/usr/local/bin 等常见目录查找,找不到就一次性给出各发行版安装命令、查过的路径与替代办法(只传 .glb/.vrm 不需要它),不再打两条 ENOENT;
  3. install.sh 新增 Assimp 检查与自动安装(deploy/assimp-lib.sh):按发行版调用 apt-get / dnf / yum / zypper / apk / pacman;装在非常规目录时自动把绝对路径写进 deploy/.env 的 ASSIMP_BIN;装不上只告警不中止(可用 SKIP_ASSIMP_INSTALL=1 跳过);
  4. deploy/check-access.sh 自检增加 Assimp 一行;管理端「概览 -> 服务健康」显示 Assimp 状态,不可用时给出提示;
  5. 新增 13 项测试(test/assimp-locate.spec.ts),覆盖定位、错误文案、快速失败与部署脚本的安装逻辑。

v0.2.1 修订:

  1. 修复部署失败:deploy/.env.example 里 MODEL_CONVERT_PMX=Hatsune Miku.pmx 的值含空格却没加引号,install.sh 用 set -a; . deploy/.env 载入时把 Miku.pmx 当命令执行,报 command not found(退出码 127);模板改为带引号写法;
  2. 新增 .env 语法预检(deploy/env-lib.sh):install.sh 与 ctl.sh 在 source 之前先检查,直接给出哪一行、原文、改法,不再出现看不懂的 127 报错;
  3. 安装失败提示带上出错文件名(.env 被 source 时行号属于 .env,只报行号无法定位);
  4. 新增 12 项回归测试(test/env-file.spec.ts),锁住「模板可被 shell 原样载入」这条线。

v0.2.0 主要变更:

  1. GLB / VRM 确定为模型主力格式,服务端与客户端分别落实支持库;
  2. 模型管理:.glb/.vrm 直接导入;.pmx/.fbx 服务端优先转 GLB、其次 VRM;压缩包自动搜索包内可用模型;入库自动生成缩略图与模型分析;
  3. 模型审核:上传一律 pending,审核界面含骨骼详细预览(后期评分依据);
  4. 新增历史模型回填(POST /api/models/:id/reanalyze + scripts/reanalyze-models.mjs);
  5. 管理端口与软件 API 端口分开(PORT / ADMIN_PORT),动作数据支持 VMD,模型支持压缩包转换导入。

部署到 Linux 服务器(业务服务,不含反向代理)

1. 初次部署(从零跑起来)

按顺序做,每步都有验证方法:

  1. 准备一台 Linux 服务器,以 root 登录(不需要 sudo),装好 Node.js 22+

    node -v                      # 期望 v22 或更高
    
  2. 准备一个现有的 MySQL / MariaDB 实例,并建好库与账号(见下方前置要求) 本项目不会创建数据库本身

  3. 取得发布包并校验(本仓库的真实发布地址, 域名 gitea.kasugano.cn、属主 zijunchiang 属部署必需信息, 不是隐私项)

    curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/opendance-web-v0.7.4.tar.gz
    curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/SHA256SUMS
    sha256sum -c SHA256SUMS      # 期望输出 "OK"
    
  4. 解压并生成配置(交互式引导,逐项问答,口令不回显)

    tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
    bash deploy/init-env.sh
    
  5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)

    bash deploy/install.sh
    
  6. 验证(把 <服务器地址> 换成实际地址 —— 它是你们自己服务器的地址, 不是被脱敏的信息;本项目的文档只对 Gitea 域名这类部署必需信息直接写明真实值)

    curl -fsS http://<服务器地址>:3000/health          # 期望 {"status":"ok",...}
    curl -sI  http://<服务器地址>:3001/ | head -1      # 期望 200,管理端页面
    curl -sI  http://<服务器地址>:3000/ | head -1      # 期望 404,API 端口不提供管理页
    
  7. 可选:回填历史模型的分析与缩略图(新装可跳过)

    cd /opt/opendance/web-server
    node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
    
  8. 浏览器打开 http://<服务器地址>:3001/ 进入管理端,上传曲目/动作/模型并审核 模型上传后状态为待审核,审核通过才会被客户端拉取

安全约定:deploy/.env 已被 .gitignore 忽略,不要提交;文档、截图、工单里也不要贴真实的数据库口令、内网地址或域名,需要时用 <占位符> 代替。

2. 职责边界

本端只提供业务服务:NestJS API + 管理端页面。反向代理由另外的服务/团队负责,本项目不安装、不配置任何反代软件,只在 deploy/proxy/ 下提供 IIS 与 nginx 的配置说明文档。

3. 部署形态

Node 业务服务由系统服务托管,同时监听两个端口,职责分开:

端口 默认 提供内容
软件 API 端口(给 Windows/Godot 客户端与 Android App) 3000 /api/ REST API、/health 健康检查、/static/ 上传文件、/contracts/ 契约快照;该端口不提供管理页面,/ 与 /songs 等一律返回 404(有意设计)
管理端端口(给浏览器) 3001 管理页面(本服务自带,含 SPA 深链接回退)+ 上面全部接口

反向代理按用途分别转发两个端口,每个端口各一条全匹配转发规则即可。

4. 前置要求

  • Linux 服务器,有 root 权限(不需要 sudo,直接以 root 执行即可)
  • Node.js 22 或更高版本
  • 进程管理:systemd / OpenRC / SysVinit 均可;三者都没有也能装(自动降级为 nohup 后台进程 + PID 文件 + crontab @reboot 开机自启)
  • Assimp 命令行工具(可选但推荐):.pmx/.fbx 模型转换与 FBX/glTF 动作预处理需要它; install.sh 会自动尝试安装(apt-get / dnf / yum / zypper / apk / pacman),装不上只告警不中止。 只上传 .glb/.vrm 模型与 .bvh/.vmd 动作则完全不需要
  • 反向代理:由另外的服务提供,本项目不要求、也不安装
  • 一个可连通的现有 MySQL / MariaDB 实例,并已创建数据库与账号:
CREATE DATABASE IF NOT EXISTS opendance
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'opendance_app'@'%' IDENTIFIED BY '<数据库口令>';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES
  ON opendance.* TO 'opendance_app'@'%';
FLUSH PRIVILEGES;

本项目不会创建数据库本身,也不需要 GRANT OPTION / FILE / SUPER 等管理权限。

5. 部署步骤

tar -xzf opendance-web-v0.7.4.tar.gz
cd opendance-web-v0.7.4
bash deploy/init-env.sh         # 首次启动引导: 逐项问答生成 deploy/.env(见下)
bash deploy/install.sh          # 已以 root 登录, 不需要 sudo

也可跳过引导手工填写:

cp deploy/.env.example deploy/.env
vi deploy/.env          # 填写 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME

脚本会依次:校验环境与 .env -> 创建服务用户与目录 -> 复制程序文件 -> 安装运行期依赖 -> 执行 TypeORM 迁移 -> 注册并启动系统服务(systemd / OpenRC / SysVinit / 后台进程)-> 等待健康检查。

6. deploy/.env 初始化引导(deploy/init-env.sh)

缺 deploy/.env 时,install.sh 会先调用本脚本引导填写;引导失败才中止安装(若安装目录里已有上次安装留下的 .env,则直接复用,不会重复提问)。也可以单独运行:逐项询问数据库与端口,回车保留默认值,写盘前做必填项与端口范围校验,并可用项目自带的 mysql2 做一次数据库连通性自检 —— 口令只经环境变量传给子进程,出错信息与摘要一律显示 *。

bash deploy/init-env.sh              # 交互引导(终端里逐项询问)
bash deploy/init-env.sh --force      # 已有 .env 也重新逐项询问
bash deploy/init-env.sh --check      # 只校验, 不改动、不提问(适合复核)

逐项询问的字段(共 8 个):DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST。

DB_PASS 用 read -s 不回显;所有字段按约定写成 键=值(不加引号),因此口令请勿含会破坏 shell 语法的字符(& $ \ 空格 ; | < > () !),脚本检测到会告警。写回只替换或追加这 8 个键、保留模板注释,写完 chmod 600并回读校验;结尾打印摘要(口令*`)与本次变更列表。

开关:

开关 说明
--force 已有 .env 也重新逐项询问
--check 只校验,不修改文件、不提问
-y, --yes, --non-interactive 非交互:只校验缺字段与端口,绝不提问
-i, --interactive 即使 stdin 不是 TTY 也强制提问(读取超时仍生效)
--skip-db-check 跳过数据库连通性自检
--dir DIR 等价于 --env-file DIR/.env --example DIR/.env.example
--env-file PATH 指定 .env 路径(默认脚本目录下的 .env)
--example PATH 指定模板路径(默认 .env 同目录的 .env.example)
--timeout SECONDS 非 TTY 下每次读取的超时秒数(默认 10)
-h, --help 显示帮助

环境变量:OPENDANCE_NONINTERACTIVE / OPENDANCE_INTERACTIVE / OPENDANCE_SKIP_DB_CHECK / OPENDANCE_ENV_FILE / OPENDANCE_ENV_EXAMPLE / OPENDANCE_PROMPT_TIMEOUT。

非交互绝不挂住:stdin 非 TTY 且是 </dev/null 或已关闭,或带 -y/--yes/--non-interactive 时只校验不提问;所有 read 都带超时,EOF 或超时立即 exit 1,不会卡在 CI 或自动化里。

7. 部署完成后(直接访问后端端口,<host> 为服务器 IP)

入口 地址
客户端接口 http://<host>:3000/api
健康检查 http://<host>:3000/health
契约快照 http://<host>:3000/contracts/
管理端首页 http://<host>:3001/

软件 API 端口上没有管理页面:http://<host>:3000/ 返回 404,属正常。

8. 对外访问

由反向代理按用途分别转发两个端口:

浏览器管理界面                     -> http://<后端地址>:3001
Windows/Godot 客户端与 Android App -> http://<后端地址>:3000

配置样例与说明(含两个端口的转发写法):

文件 用途
deploy/proxy/nginx.conf.example nginx 站点配置
deploy/proxy/web.config.example IIS(ARR + URL Rewrite)配置
deploy/proxy/README.md 完整说明、自检清单与常见故障

直连后端时,防火墙需同时放行两个端口(默认 3000 与 3001)。

外部访问不了?安装脚本结尾会直接给出结论(监听地址、对外可达性、防火墙提示);也可以在服务器上跑一次自检脚本(只读,不改配置):

bash deploy/check-access.sh            # 或 bash deploy/check-access.sh /opt/opendance

它会打印 .env 里的 HOST/PORT、实际监听地址、回环与对外 IP 自测结果、80/443 是否有反代、防火墙放行情况,并直接给出修复命令。

9. 可覆盖的安装参数

参数 默认值 说明
APP_DIR /opt/opendance 程序目录
SERVICE_USER opendance 运行服务的系统用户
STORAGE_DIR /var/lib/opendance/storage 上传文件目录
PORT 3000 软件 API 端口
ADMIN_PORT 3001 管理端端口(默认 PORT + 1)

例:APP_DIR=/srv/opendance bash deploy/install.sh。

10. 升级

解压新版本后重新执行 bash deploy/install.sh 即可(会覆盖程序文件,保留 deploy/.env 与上传数据)。

11. 卸载

sudo bash deploy/uninstall.sh           # 停服务、禁用站点, 保留数据
sudo bash deploy/uninstall.sh --purge   # 一并删除程序目录与上传文件

数据库始终由部署方自行管理,卸载脚本不做任何改动。

12. 常用命令(按安装时自动检测到的初始化系统而定)

# systemd
systemctl status opendance-api      # 服务状态
journalctl -u opendance-api -f      # 实时日志
systemctl restart opendance-api     # 重启

# OpenRC
rc-service opendance-api status / restart
tail -f /var/log/opendance-api.log

# SysVinit
/etc/init.d/opendance-api status / restart

# 无 init 系统(降级模式)
bash /opt/opendance/deploy/ctl.sh status | restart | logs

反代配置不在本机,无需在此维护。

13. 常见问题(初次部署)

现象 处理
安装脚本报"未找到 node"或 Node 版本过低 装 Node.js 22+ 后重跑;脚本会打印检测到的版本号
安装脚本报"deploy/.env 中 DB_* 未设置" 先跑 bash deploy/init-env.sh 生成配置,或手工 cp deploy/.env.example deploy/.env 后填写
健康检查返回 503 服务起来了但连不上数据库:核对 deploy/.env 的 DB_HOST/DB_PORT/DB_USER/DB_PASS/DB_NAME,以及数据库账号是否允许从本机 IP 连接(GRANT ... TO 'user'@'%' 或具体主机)
服务本机 curl 通、外部访问不了 bash deploy/check-access.sh 一次定位:打印 HOST/PORT、实际监听地址、对外 IP 自测、80/443 有无反代、防火墙放行情况,并给出修复命令
管理页面打开是 404 说明访问的是软件 API 端口(默认 3000);管理页面在管理端端口(默认 3001)
上传 .zip / .pmx / .fbx 报转换失败(ENOENT / Assimp 不可用) PMX/FBX 转换依赖外部 Assimp:apt-get install -y assimp-utils(RHEL 系 dnf install -y assimp,install.sh 现在会尝试自动安装)。装好后不需要重启服务;若不在服务 PATH 中,在 deploy/.env 设 ASSIMP_BIN 为绝对路径。只上传 .glb / .vrm 则不需要它
导入后的模型颜色/观感与 MMD 里不一致 PMX 的着色(卡通阶梯、球面贴图、平坦环境光)在 glTF 里没有等价实现。v0.2.5 起会补回双面、规范化 PBR 参数并清掉无关扩展;想要最接近 MMD 的平涂颜色,把 MODEL_SHADING=unlit 写进 deploy/.env 后重新导入模型。球面贴图与 toon 阶梯无法保留,数量会记在模型的"转换说明"里
列表/审核里的缩略图是"白模"(纯白模型) v0.2.4 前的缩略图渲染器不采样贴图,而 MMD 模型的 baseColorFactor 是白色。升级到 v0.2.4 后重新生成缩略图:cd /opt/opendance/web-server && node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --force
导入模型返回 500 Internal server error 老库上多为 models.stats 超长(骨骼树按骨骼展开,643 骨骼即 70,980 字节 > TEXT 上限):执行 ALTER TABLE models MODIFY stats MEDIUMTEXT NULL; 或部署 v0.2.3(install.sh 会自动跑迁移)。v0.2.3 起服务端另有兜底,不会再 500
安装时报 command not found / 退出码 127,且提示指向 deploy/.env 某一行 该行的值含空格或 shell 元字符却没加引号,被 shell 当成命令执行了。按预检提示给这个值加引号(如 MODEL_CONVERT_PMX="Hatsune Miku.pmx")后重跑;deploy/env-lib.sh 会提前拦住并给出行号
模型审核界面显示"未分析 / 无预览图" 该模型是升级前入库的:调 POST /api/models/<id>/reanalyze,或批量跑 node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000

本地开发

后端:

cd web-server
cp .env.example .env    # 填写 DB_*
npm install
npm run migration:run   # 首次需要
npm run start:dev       # API http://localhost:3000; 管理端 http://localhost:3001

管理前端:

cd web-server/admin
npm install
npm run dev             # http://localhost:5173(/api 与 /health 代理到 3000)

验证

cd web-server
npm run contracts:check   # 契约版本一致性(6 项契约版本 + 35 条路由 + 三端版本号)
npm run typecheck         # TypeScript 严格检查
npm run test              # 309 项单元测试 / 27 个文件。分布(VMD 31、模型检查器 29、
                          #   模型压缩包 27、预处理 25、admin-port 16、init-env 16、
                          #   Assimp 定位与安装 13、应用版本 12、目录导出 12、.env 预检 12、
                          #   访问日志 9、MMD 材质对齐 9、stats 容量与编码 9、场景配置 9、
                          #   骨骼过滤 8、缩略图 8、动作预览 8、缩略图贴图采样 6、
                          #   真实素材回归 6、场景降级 6、VRM 检查器 6、模型可用性 5、
                          #   场景上传 5、实体注册 4、场景端到端(真实 sql.js 库) 4)
                          #   opt-in: test/real-samples.spec.ts 缺样例/Assimp 时跳过 3 项
npm run build             # 生产构建
npm run start:prod &      # 启动
npm run test:smoke        # 81 项端到端冒烟(需要数据库可连通)

cd ../admin && npm run build   # 管理端构建

真实素材回归(需要仓库根的 示例文件/ 与外部 Assimp;样例缺失时自动跳过):

cd web-server
ASSIMP_BIN=/usr/bin/assimp npm test -- test/real-samples.spec.ts
# 样例放在别处: OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=... npm test
# 覆盖: 两个真实 PMX→VRM(初音 19/22、奥黛塔 21/22 humanoid) + 真实 VMD→33 点内部标准

环境变量

变量 默认 说明
DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME - 现有 MySQL 实例连接信息(必填)
DB_SYNCHRONIZE false 必须为 false。生产环境启动时若检测到 true 会直接中止启动;所有表结构变更通过 TypeORM migration 执行
DB_LOGGING false 是否打印 SQL
PORT 3000 软件 API 监听端口。给 Windows/Godot 客户端与 Android App 调用,只提供 /api、/health、/static、/contracts,不提供管理页面
ADMIN_PORT PORT + 1(3001) 管理端端口。浏览器访问管理界面(管理页面 + 上面全部接口);设为与 PORT 相同即为单端口兼容模式,日志会提示「未分端口」
HOST 0.0.0.0 监听地址(软件 API 端口),因此装好即可被客户端访问(此时请用防火墙限制来源)。反代与后端同机时建议改为 127.0.0.1,让后端不直接对外暴露
ADMIN_HOST 跟随 HOST 管理端监听地址(可选;HOST 未设置时同样回落 0.0.0.0)。需要接口对外、管理端只对本机时设为 127.0.0.1
ADMIN_DIR ../admin(即 /opt/opendance/admin) 管理端静态站点目录
STORAGE_DIR /var/lib/opendance/storage 上传文件存储目录(原生部署默认值)
UPLOAD_MAX_BYTES 536870912(512 MiB) 单文件上限。反向代理层的同名限制必须不小于此值,否则大模型上传会在代理层被拒(nginx client_max_body_size / IIS requestLimits),详见 deploy/proxy/README.md
ASSIMP_BIN assimp Assimp 可执行文件(install.sh 会自动安装并解析出绝对路径写入此项)。.pmx/.fbx 模型转换与 FBX/glTF 动作预处理需要;缺失时上传会失败并给出安装命令,只上传 .glb/.vrm 不受影响
MODEL_CONVERT_FORMAT vrm 需要转换的模型(PMX/FBX)的交付格式:glb(两个客户端都原生加载)或 vrm。vrm = 在 GLB 之上补 VRMC_vrm 扩展(humanoid 骨骼 / 表情语义),humanoid 骨骼映射不全时自动回退交付 GLB 并写进 conversion_note。产物一律用 assimp info 回读校验
MODEL_CONVERT_PMX "Hatsune Miku.pmx" 压缩包内优先选用的 PMX 文件名(大小写不敏感);找不到时按「格式优先级 -> 目录最浅 -> 文件最大」挑选
MODEL_SHADING pbr 转换后模型的着色模式。pbr:标准 PBR,并按 PMX 参数修正 doubleSided / metallic / roughness;unlit:写 KHR_materials_unlit,颜色 = 贴图 x baseColorFactor、与场景光照无关,最接近 MMD 的平涂观感
MODEL_CONVERT_TIMEOUT_MS 180000 单次转换超时(毫秒)
DEFAULT_MODEL_ID - 可选:显式指定默认模型 id;不设置时按源码种子 src/config/default-model.ts 匹配,再回落到 VRM 优先规则
CONTRACTS_DIR - 可选:契约目录,设置后 /health 反映实时契约版本(原生部署默认指向 /opt/opendance/contracts)

端口配置非法(非 1..65535 整数)时,生产环境启动会直接中止;ADMIN_PORT 设为与 PORT 相同只打印提示,不中止。

.env 写法约定:install.sh / ctl.sh 用 set -a; . deploy/.env 载入配置,shell 会真的执行这个文件,因此

  • 普通值不加引号:PORT=3000、DB_HOST=127.0.0.1;
  • 值里含空格或 shell 元字符(& | ; $ \ < > ( )等)时**必须加引号**,例如MODEL_CONVERT_PMX="Hatsune Miku.pmx"`;
  • 双引号内不要用 $(...) 或反引号(会被真的执行),需要字面量时用单引号:KEY='a b'。

两个脚本在 source 之前都会先跑 deploy/env-lib.sh 的语法预检,不安全写法会直接指出行号、原文与改法。

主要 API

方法 路径 说明
GET /health 健康检查(含数据库连通性)
GET /api/songs 曲目列表(分页 / 检索)
POST /api/songs 新建曲目
GET /api/songs/:id 曲目详情
PATCH /api/songs/:id 更新曲目
DELETE /api/songs/:id 删除曲目(软删除)
POST /api/songs/:id/audio 上传音频
GET /api/motions 动作列表
POST /api/motions 上传动作 + 来源信息
POST /api/motions/:id/preprocess BVH/VMD/FBX -> MediaPipe33 关键点
GET /api/motions/:id/keypoints 下载关键点序列(NDJSON)
GET /api/models 模型列表
POST /api/models 上传模型 + 来源信息(.glb/.vrm/.gltf 直接导入;.pmx/.fbx 服务端转换;.zip 自动搜索包内模型)
POST /api/models/:id/primary 设为全局默认模型(仅 published 允许,否则 409)
GET /api/reviews 审核流水 / 待审列表
POST /api/reviews/:type/:id/approve 审核通过(模型可带 setPrimary:true)
POST /api/reviews/:type/:id/reject 审核驳回(必须填意见)
GET /api/catalog/export 客户端一次性拉取已发布目录
GET /api/catalog/version 契约版本 + 目录指纹

完整契约(OpenAPI 3.0)见 contracts/rest-api.yaml。

上传文件通过 /static/<kind>/<yyyy>/<mm>/<sha256>.ext 访问,相同内容只落盘一次(sha256 去重)。

支持的模型格式(POST /api/models 上传)

系统以 GLB / VRM 为主力格式:服务端与客户端都把这两种格式作为一等公民(Godot 客户端用内置 GLTFDocument 加载 glTF/GLB/VRM,见 opendance-windows/src/model/vrm_loader.gd,本次无需改动)。

格式 扩展名 优先级 处理方式
glTF Binary .glb 主力(P0) 直接导入,不重编码
VRM .vrm 主力(P0) 直接导入;GLB + VRM 扩展(humanoid / 表情 / springBone / meta)
glTF .gltf P1 直接导入(分离的 .bin / 贴图需随 .zip 一起上传)
MMD 压缩包 .zip P1 自动搜索包内可用模型(见下)
MMD 模型 .pmx P2(输入) 服务端转换,优先 GLB、其次 VRM
FBX .fbx P2(输入) 服务端转换,优先 GLB、其次 VRM

入库后一律 status=pending(待审核):上传不再自动发布、不再自动设为默认,必须经审核通过(模型审核面板可勾选「同时设为全局默认模型」)才对客户端可见。

升级后回填已有模型(面数 / 骨骼 / 缩略图)

升级到带 models.stats 的版本后,旧模型还没有分析与预览图。服务启动后执行一次:

cd /opt/opendance/web-server
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
# 只处理一个: --model-id 14    只列清单: --dry-run    全部重算: --force

也可对单个模型调 POST /api/models/:id/reanalyze(只改 stats 与缩略图两个字段)。若模型文件在存储中已丢失,接口返回 409 并提示先恢复文件,不会用空结果覆盖已有数据。

模型压缩包(.zip)自动搜索与转换

上传的 .zip 不再"只找 .pmx",而是自动搜索包内可用模型,走"解压 -> 选主模型 -> 直接导入或转换 -> 回读校验 -> 入库",而不是把压缩包当模型存下来。

环节 行为
选主模型 优先级 .vrm -> .glb -> .gltf -> .pmx -> .fbx;同一优先级内按"MODEL_CONVERT_PMX 指名字 -> 目录最浅 -> 文件最大"。命中 .vrm/.glb/.gltf 时直接导入(不重编码)
转换 只有 .pmx/.fbx 需要转换。链路:assimp export -> .glb(首选);失败则 assimp export -> .gltf,再由 gltf-packer.ts 自行打包回 GLB;MODEL_CONVERT_FORMAT=vrm 时在 GLB 之上补 VRMC_vrm 扩展封装成 VRM 1.0(humanoid 骨骼不全则回退交付 GLB,缺失骨骼写进说明)
校验 每一步产物必须用 assimp info 回读校验。Assimp 会"写出损坏文件"(实测 PMX 导出 FBX 退出码 11、回读报 FBX-DOM 错误),只看退出码会把坏文件入库
贴图 GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠),服务端把压缩包内 / .gltf 同目录的贴图内嵌进 GLB 的二进制块,保证单文件可加载
说明 每次尝试写入 models.conversion_note,例如 Hatsune Miku.pmx -> GLB, 已内嵌 2 张贴图
溯源 压缩包原件以 model-archive 资产留存,写入 models.source_asset_id;迁移 AddModelConversionFields1789450000000
失败 包内没有可用模型、Assimp 不可用、两条路径都失败 -> HTTP 422,不产生模型记录
文件名编码 upload.util.ts 的 decodeUploadName() 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先会存成乱码)
管理端 上传框 accept 为 .vrm,.glb,.gltf,.fbx,.zip;预览面板显示「转换来源」、「文件 sha256」与面数 / 顶点 / 骨骼分析摘要
新增字段 DTO/契约新增 conversionNote、fileSha256、stats、thumbnailUrl

实测(Assimp 6.0.4):

  • 上传含 Hatsune Miku.pmx 的 MMD 压缩包 -> HTTP 201 / 0.86s、format=glb、status=pending、conversionNote=Hatsune Miku.pmx -> GLB, 已内嵌 2 张贴图。分析结果:vertices=7155 triangles=9071 materials=3 textures=2 images=2 nodes=240 bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23;missingRequiredBones 为全部 16 根(日文 MMD 骨名无法映射到 humanoid,审核界面会直接标红)。
  • 直接上传 .glb -> HTTP 201 / 0.36s、conversionNote=直接导入(GLB, 未做重编码),入库文件 sha256 与源文件一致(不重编码,字节级保留)。

模型分析、缩略图与 3D 预览(入库后自动执行,失败不影响入库)

能力 实现
分析 model-inspector.ts 解析 GLB/VRM:面数 / 顶点 / 网格 / 材质 / 贴图 / 节点 / 形变目标 / 动画 / 蒙皮骨骼数 / 骨骼树 / humanoid 覆盖 / 可驱动性(缺哪些必需骨骼)/ warnings,写入 models.stats(迁移 AddModelStats1789460000000,新增 models.stats TEXT)
缩略图 model-thumbnail.ts 用纯 TS 软件光栅化生成 PNG(读 GLB/glTF -> 应用节点层级与蒙皮 -> 包围盒自动构图 -> 透视投影 -> z-buffer + 平面着色 + 逐像素采样 baseColorTexture,内置 PNG 解码器 -> 手写 PNG 编码),零原生依赖(不需要 canvas / headless-gl / puppeteer);按 alphaMode 处理 OPAQUE / MASK / BLEND。默认 512x512,写入 models.thumbnail_asset_id / thumbnailUrl;失败或非 glTF 容器时留空,列表页退化为格式占位图
预览 管理端 3D 预览放开到 vrm/glb/gltf(.fbx 仍提示「仅归档,请先转换为 GLB/VRM」);列表页显示 40px 小缩略图,审核面板显示 116px 缩略图

实测:512x512 PNG / 15-42 KB(带贴图后变大),目视确认为居中的 T-pose 角色图且能看到肤色/眼睛/服装贴图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms),27MB PMX 转换件约 200-400ms。

审核流程(模型)

模型上传后为 pending,在管理端「审核」页的模型审核面板里查看并决定:

项 内容
面板内容 体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / humanoid 覆盖(已映射/总数 + 明细)/ 可驱动性结论(缺哪些必需骨骼)/ warnings
骨骼预览 骨骼详细界面:扁平骨骼树按 depth 缩进、显示骨骼名、是否参与蒙皮、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + 只渲染前 400 行(千根骨骼不卡)
通过 勾选「同时设为全局默认模型」-> 请求体带 setPrimary:true(POST /api/reviews/model/:id/approve);此时才写库 is_primary 并把默认模型身份写进源码 src/config/default-model.ts
单独设置 POST /api/models/:id/primary(仅 published 允许,否则 409)
驳回 必须填写审核意见,否则 400

默认模型是怎么定的

GET /api/catalog/export 的 defaultModelId 按以下顺序解析:

  1. 环境变量 DEFAULT_MODEL_ID(且模型存在且已发布)
  2. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED —— 按上传压缩包的 sha256 匹配已发布模型(用户文件永远稳定;不能用转换产物的 sha256,Assimp 每次导出的字节都略有不同),换库 / 重建库后仍有效
  3. format='vrm' 且 published 且 is_primary=1
  4. 任意 published 的 VRM(id 升序取第一个)
  5. id 最小的已发布模型
  6. 无可用模型 -> 客户端用内置占位模型

源码种子 src/config/default-model.ts 当前为空种子(占位:id=null、sha256 空串),此时 defaultModelId 直接从第 2 步的 VRM 优先规则开始解析;按下面的方式设定默认模型后,服务端会把种子写成真实内容。

实测(v0.2.0):POST /api/reviews/model/22/approve 带 setPrimary:true -> 200,模型 status=published、isPrimary=true,源码种子写入 sourceSha256=72eb6882...;重建后 GET /api/catalog/export 的 defaultModelId 由 VRM 变为 22(库中另有 2 个已发布 VRM)。也可手工指定:POST /api/models/:id/primary。

第 1 步的源码写入由服务端在审核通过并勾选 setPrimary(或调用 primary 接口)时完成;生产 systemd 加固(ProtectSystem=strict)下服务进程写不了程序目录,可改用下面的 CLI 补写。

导入 / 补写默认模型(web-server/scripts/import-default-model.mjs)

需先 npm run build(脚本复用编译产物 dist/models/default-model-source.js)。

cd web-server
node scripts/import-default-model.mjs 模型.zip \
  --url http://127.0.0.1:3000 \
  --name "初音未来 Hatsune Miku" --author "Boku Wa Luka" \
  --license "MMD 模型使用条款(见压缩包内说明)" \
  --source-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
  --source-url "本地文件: 模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
  --source-type other
# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts

node scripts/import-default-model.mjs --write-source-only --model-id 22
# 只根据已入库模型补写源码, 不调上传 API

node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run
# 只打印将写入的内容, 不落盘

说明:--url 默认 http://127.0.0.1:3000,--source-type 默认 other。v0.2.0 起上传一律 pending,因此 CLI 上传路径只负责转换入库与写源码种子;要让它在 defaultModelId 中生效,仍需在管理端审核通过(勾选「同时设为全局默认模型」)或调用 POST /api/models/:id/primary。

支持的动作格式(POST /api/motions 上传 -> POST /api/motions/:id/preprocess 预处理)

格式 扩展名 处理方式
BVH .bvh 内置纯 TS 解析器,保留源帧率
VMD .vmd 内置解析(MikuMikuDance),固定 30fps;腿部按「足IK是否带位移」自动选 IK 或 FK
FBX .fbx 需可选的 Assimp 先转 BVH
glTF / GLB .gltf / .glb 同上
关键点 JSON .json 已是 MediaPipe33 结构,只做校验与规范化

VMD 动作上传前后自查

服务端对 .vmd 是内置解析,不依赖 Assimp。上传前可先在 web-server 目录下跑诊断工具(只读:不写数据库、不访问网络),确认骨骼名能识别、腿走 IK 还是 FK、关键点序列无异常:

cd web-server
npm run build
node scripts/inspect-vmd.mjs 动作.vmd
node scripts/inspect-vmd.mjs 动作.vmd --frames 300      # 只采样前 300 帧
node scripts/inspect-vmd.mjs 动作.vmd --json out.json   # 导出关键点序列

它会打印头部 / 模型名 / 帧数 / 轨道数、未识别骨骼、走 IK 还是 FK、关键点序列异常统计与膝肘角分布。上传后再调用 /preprocess 走正式通路。

目录结构

project.yaml            任务清单与状态
DSH.md                  本项目 DSH 执行约束
tasks/                  单任务说明书(T-WEB-001 ~ 010)
contracts/              跨端契约(权威, 改接口先改这里)
web-server/             NestJS 服务端源码
web-server/admin/       React 管理端
web-server/src/common/zip-reader.ts            纯 TS ZIP 读取器
web-server/src/models/model-convert.ts         .zip/.pmx/.fbx -> GLB/VRM 转换
web-server/src/models/gltf-packer.ts           glTF(分离文件)-> GLB 打包
web-server/src/models/glb-textures.ts          GLB 贴图内嵌
web-server/src/models/vrm-writer.ts            GLB -> VRM 1.0 封装(写 VRMC_vrm 扩展)
web-server/src/models/model-inspector.ts       模型检查器(面数/骨骼树/humanoid 覆盖)
web-server/src/models/model-thumbnail.ts       纯 TS 光栅化缩略图(零原生依赖)
web-server/src/config/default-model.ts         默认模型种子(服务端/CLI 写入)
web-server/src/migrations/1789460000000-AddModelStats.ts
                                                models.stats 迁移(审核分析结果)
web-server/admin/src/components/ModelReviewPanel.tsx
                                                模型审核面板(含骨骼详细界面)
web-server/admin/src/lib/bone-tree.ts          骨骼树过滤(搜索保留祖先链)
web-server/scripts/import-default-model.mjs    默认模型导入 / 补写 CLI
deploy/                 业务服务部署(系统服务, 不含 MySQL、不含反向代理)
deploy/init-env.sh      首次启动的 .env 交互引导(install.sh 会调用)
deploy/proxy/           反向代理配置说明(nginx / IIS, 仅供参考不部署)
THIRD_PARTY.md          第三方开源项目声明

安全提示

  1. .env 已被 .gitignore 忽略,禁止提交;仓库中只保留 .env.example。
  2. 数据库账号使用最小权限,不授予 GRANT OPTION / FILE / SUPER。
  3. 本版本未内置管理端登录认证,请部署在内网,或在反向代理层加 Basic Auth / SSO。
  4. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见。模型上传后一律 pending:不再自动发布、不再自动设为默认;默认模型只在审核通过时勾选「同时设为全局默认模型」(setPrimary:true)或调用 POST /api/models/:id/primary(仅 published)时才设置(见审核流程)。
  5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。

第三方开源声明

见 THIRD_PARTY.md,或访问站点 http://<host>/ 的「关于」页面。