Files
opendance-web/tasks/004-motion-upload.md
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

3.0 KiB
Raw Blame History

T-WEB-004 动作数据上传 + 来源字段

  • 状态:done
  • 依赖:T-WEB-002

目标

上传动作文件(BVH / VMD / FBX / glTF / GLB / keypoints-json),强制填写来源五元组。

load

DSH.md
contracts/rest-api.yaml          # 锚点:#/paths/~1api~1motions
contracts/data-models.md         # 锚点:§3 Motion
contracts/model-formats.md       # 锚点:§2 动作格式、§3 上传限制
web-server/src/motions/*
web-server/src/storage/storage.service.ts
web-server/src/preprocess/vmd-parser.ts   # VMD 上传后的预处理链路
web-server/src/preprocess/mmd-skeleton.ts
web-server/src/preprocess/mmd-pose.ts
web-server/src/preprocess/retarget.ts     # worldFramesToKeypointFrames()

端点

方法 路径 说明
GET /api/motions 分页 + q/songId/format/status 过滤
POST /api/motions multipart 上传
GET /api/motions/:id 详情
PATCH /api/motions/:id 更新元数据(含 detachSong)
DELETE /api/motions/:id 软删除

存储策略(contracts/model-formats.md §3)

${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
  • 上传先落 ${STORAGE_DIR}/tmp(multer diskStorage),避免大文件占用内存
  • StorageService 流式计算 sha256;相同内容只落盘一次,复用 assets 记录
  • 并发同文件上传靠 assets.sha256 唯一索引兜底,冲突后回退到已存在记录
  • 路径永不使用原始文件名;absolutePath() 拒绝逃逸存储根目录

校验

  • 扩展名白名单:.bvh .vmd .fbx .gltf .glb .json,其他 → 400
  • 来源五元组缺任一项 → 400
  • songId 指向不存在的曲目 → 400
  • 单文件上限 UPLOAD_MAX_BYTES(默认 512 MiB)

VMD 支持(.vmd)

  • 管理端上传控件已支持 .vmd(accept、格式筛选、中文标签「VMD(MMD)」)
  • 入库后 format=vmd;预处理走内置链路,不依赖 Assimp: vmd-parser.ts(二进制 + Shift-JIS + 贝塞尔插值)→ mmd-skeleton.ts(标准骨架) → mmd-pose.ts(FK + 腿部 IK + Z 镜像)→ retarget.ts 的 worldFramesToKeypointFrames()(与 BVH 共用骨盆归零 / 自适应缩放 / 投影 / 关节角)
  • 固定 30 fps;腿部按 足IK 是否带位移自动选 IK 或 FK
  • 上传后处理失败与 BVH 一致:写入 preprocessState=failed 与可读错误

验收

npm run test:smoke

冒烟 [2] 段落:上传 201、自动识别 format=bvh、fileSize 记录、缺来源 400、 evil.exe 400。

VMD 实测(用 ../示例文件/ 下的两个样本):

样本 帧数 腿部驱动 结果
【动作】霜雪千年.vmd(0.79 MiB) 2901 足IK 驱动(IK) 0 NaN / 0 越界,预处理约 0.4s
恋爱循环.vmd(20.24 MiB) 4604 烘焙 FK 0 NaN / 0 越界,预处理约 2.5s

上传前可先用诊断工具自查(只读):

node scripts/inspect-vmd.mjs 动作.vmd [--frames N] [--json out.json]