From 60c182f1b281d5dc347b907bc38066d7df2f9291 Mon Sep 17 00:00:00 2001 From: opendance Date: Tue, 15 Sep 2026 21:45:51 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B6=88=E9=99=A4=20Gitea=20=E6=A8=A1?= =?UTF-8?q?=E6=A3=B1=E4=B8=A4=E5=8F=AF=20Unicode=20=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=EF=BC=88=E5=85=A8=E8=A7=92=E6=A0=87=E7=82=B9=E7=B4=A7=E9=82=BB?= =?UTF-8?q?=20ASCII=20=E6=97=B6=E6=94=B9=E4=B8=BA=E5=8D=8A=E8=A7=92?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 Gitea v1.27.3 modules/charset/escape_stream.go 的判定规则 (analyzeDetectResults:仅当左右相邻字符都不是非 ASCII 时才标记, 且 _common 表恒定生效),把说明文档中会被标记的全角标点 (,:;?!() 等)改为半角,并保证成对括号宽度一致。 中文语境中的全角标点(左右均为中文)保持原样,不动代码与契约语义。 同时删除临时探针目录 _probe/(仅用于确认 Gitea 判定规则)。 --- DSH.md | 102 +++++++++++++++---------------- THIRD_PARTY.md | 2 +- _probe/ambiguity.txt | 16 ----- _probe/chunk-0.txt | 40 ------------ _probe/chunk-1.txt | 41 ------------- _probe/chunk-2.txt | 40 ------------ _probe/chunk-3.txt | 41 ------------- _probe/chunk-4.txt | 41 ------------- _probe/chunk-5.txt | 40 ------------ _probe/chunk-6.txt | 40 ------------ _probe/chunk-7.txt | 40 ------------ contracts/data-models.md | 6 +- contracts/database.md | 22 +++---- contracts/model-formats.md | 58 +++++++++--------- contracts/skeleton-topology.md | 14 ++--- contracts/websocket-keypoints.md | 12 ++-- deploy/proxy/README.md | 26 ++++---- tasks/001-scaffold.md | 6 +- tasks/002-db-migration.md | 8 +-- tasks/003-song-crud.md | 6 +- tasks/004-motion-upload.md | 26 ++++---- tasks/005-model-upload.md | 72 +++++++++++----------- tasks/006-keypoint-preprocess.md | 20 +++--- tasks/007-upload-review.md | 26 ++++---- tasks/008-admin-ui-songs.md | 12 ++-- tasks/009-admin-ui-models.md | 12 ++-- tasks/010-deploy.md | 94 ++++++++++++++-------------- web-server/admin/README.md | 28 ++++----- 28 files changed, 276 insertions(+), 615 deletions(-) delete mode 100644 _probe/ambiguity.txt delete mode 100644 _probe/chunk-0.txt delete mode 100644 _probe/chunk-1.txt delete mode 100644 _probe/chunk-2.txt delete mode 100644 _probe/chunk-3.txt delete mode 100644 _probe/chunk-4.txt delete mode 100644 _probe/chunk-5.txt delete mode 100644 _probe/chunk-6.txt delete mode 100644 _probe/chunk-7.txt diff --git a/DSH.md b/DSH.md index 8e0e22f..d70048b 100644 --- a/DSH.md +++ b/DSH.md @@ -1,4 +1,4 @@ -# DSH.md -- opendance-web(OpenDance Studio 项目一:Web 管理服务端) +# DSH.md -- opendance-web(OpenDance Studio 项目一:Web 管理服务端) ## 0. 本文件的作用 @@ -31,9 +31,9 @@ opendance-web ←── 本仓库 | 管理前端 | React 18 + Vite + TypeScript | | 3D 预览 | three.js + @pixiv/three-vrm | | 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp | -| 模型主力格式 | **GLB / VRM 为一等公民**:`.glb`/`.vrm`/`.gltf` 直接导入(不重编码),`.pmx`/`.fbx` 服务端转换(优先 GLB、其次 VRM) | +| 模型主力格式 | **GLB / VRM 为一等公民**: `.glb`/`.vrm`/`.gltf` 直接导入(不重编码),`.pmx`/`.fbx` 服务端转换(优先 GLB、其次 VRM) | | 模型检查 / 缩略图 | `models/model-inspector.ts`(面数 / 顶点 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性)、`models/model-thumbnail.ts`(纯 TS 光栅化 PNG,零原生依赖) | -| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 自动搜索包内模型 + 外部 Assimp 转换(`models/model-convert.ts`);GLB 打包 `models/gltf-packer.ts`、贴图内嵌 `models/glb-textures.ts`、GLB->VRM 封装 `models/vrm-writer.ts` | +| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 自动搜索包内模型 + 外部 Assimp 转换(`models/model-convert.ts`);GLB 打包 `models/gltf-packer.ts`、贴图内嵌 `models/glb-textures.ts`、GLB->VRM 封装 `models/vrm-writer.ts` | | 默认模型种子 | `web-server/src/config/default-model.ts`(服务端/CLI 写入,运行期只读) | | 认证 | **本版本不含认证**(内网部署);上线前须补 >= T-WEB-011 | @@ -69,7 +69,7 @@ opendance-web ←── 本仓库 opendance-web/ ├── project.yaml # 任务清单与状态 ├── DSH.md # 本文件 -├── tasks/ # 单任务说明书(001~010) +├── tasks/ # 单任务说明书(001~010) ├── contracts/ # ★ 跨端契约(权威) │ ├── VERSION # 契约版本(唯一来源) │ ├── rest-api.yaml # OpenAPI 3.0 @@ -80,11 +80,11 @@ opendance-web/ │ ├── database.md # 现有 MySQL 连接与迁移约定 │ └── open-source-credits.md # 开源声明锚点(三端共用) ├── web-server/ # NestJS 服务端 -│ ├── src/ # 源码(entities / models / preprocess) +│ ├── src/ # 源码(entities / models / preprocess) │ │ ├── common/zip-reader.ts # 纯 TS ZIP 读取器(STORE/DEFLATE、ZIP64、zip-slip 防护) │ │ ├── models/ # 模型上传 + 自动搜索/转换 + 分析 + 缩略图 │ │ │ ├── model-convert.ts # 选主模型 -> 直接导入或 Assimp 转换 -> 回读校验 -│ │ │ ├── gltf-packer.ts # glTF(.gltf +.bin + 贴图)-> GLB 打包 +│ │ │ ├── gltf-packer.ts # glTF(.gltf +.bin + 贴图)-> GLB 打包 │ │ │ ├── glb-textures.ts # 把外部贴图内嵌进 GLB 的 BIN chunk │ │ │ ├── vrm-writer.ts # GLB -> VRM 1.0(写 VRMC_vrm 扩展) │ │ │ ├── model-inspector.ts # 面数 / 顶点 / 骨骼树 / humanoid 覆盖 / 可驱动性 @@ -92,12 +92,12 @@ opendance-web/ │ │ │ ├── default-model-source.ts # 渲染 / 写入 src/config/default-model.ts │ │ │ └── vrm-inspector.ts │ │ ├── config/default-model.ts # 默认模型种子(由审核 / CLI 写入,运行期只读) -│ │ ├── migrations/ # TypeORM 迁移(AddModelConversionFields / AddModelStats) +│ │ ├── migrations/ # TypeORM 迁移(AddModelConversionFields / AddModelStats) │ │ └── preprocess/ # BVH / VMD / FBX -> MediaPipe33 │ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值 -│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX) +│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX) │ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系->右手系 Z 镜像 -│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用 +│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用 │ ├── admin/ # React 管理端 │ │ ├── src/components/ModelReviewPanel.tsx # 模型审核面板 + 骨骼详细界面 │ │ └── src/lib/bone-tree.ts # 骨骼树过滤(搜索保留祖先链) @@ -107,7 +107,7 @@ opendance-web/ │ │ # / preprocess 25 / init-env 16 / admin-port 16 │ │ # / access-log 9 / vrm-inspector 6 │ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs -│ # / inspect-vmd.mjs(VMD 诊断) +│ # / inspect-vmd.mjs(VMD 诊断) │ # / import-default-model.mjs(默认模型导入 / 补写源码) ├── deploy/ # ★ 业务服务部署(不含 MySQL、不含反代) │ ├── init-env.sh # ★ 首次启动的.env 交互引导(install.sh 缺.env 时调用) @@ -126,15 +126,15 @@ opendance-web/ ## 6. 硬性约束 1. **不部署 MySQL**。数据库为部署方提供的现有实例,连接信息只经环境变量注入。 - **也不使用 Docker**,**也不部署反向代理**:本端只提供业务服务 - (API + 自带的管理端页面),反代由另外的服务负责, + **也不使用 Docker**, **也不部署反向代理**:本端只提供业务服务 + (API + 自带的管理端页面),反代由另外的服务负责, 本项目只在 `deploy/proxy/` 下提供 IIS 与 nginx 的配置说明文档。 业务服务监听**两个端口**(软件 API 端口 `PORT` + 管理端端口 `ADMIN_PORT`), 约定见 9。 2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个 migration 必须可逆。 3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。 -4. **来源五元组强制必填**:`source_type` / `source_name` / `source_url` / `license` +4. **来源五元组强制必填**: `source_type` / `source_name` / `source_url` / `license` / `author`。任何资产入库缺一即 400。 5. **契约先行**:改接口先改 `contracts/`,再提升 `contracts/VERSION`,同步 `web-server/src/config/contract-versions.ts`,最后才改实现。 @@ -142,12 +142,12 @@ opendance-web/ 6. **不使用 SMPL** 及其衍生拓扑(许可风险),统一 MediaPipe 33 点。 7. **上传文件永不使用原始文件名作为路径**,一律 `sha256` 去重后归档。 8. **GLB / VRM 是模型主力格式**:服务端与客户端(Godot 内置 `GLTFDocument`)都把 - 这两种格式作为一等公民。`.glb` / `.vrm` / `.gltf` **直接导入、不重编码**; + 这两种格式作为一等公民。`.glb` / `.vrm` / `.gltf` **直接导入、不重编码**; `.pmx` / `.fbx` 才由服务端转换,且**优先 GLB、其次 VRM**。 humanoid 候选表必须与客户端 `opendance-windows/src/model/humanoid_map.gd` 逐字对齐, 由 `test/model-inspector.spec.ts` 断言兜底。 9. **模型上传一律 `pending`(待审核)**:上传不得自动发布、不得自动设为默认模型。 - 默认模型只在审核通过勾选 `setPrimary:true`(`POST /api/reviews/model/:id/approve`) + 默认模型只在审核通过勾选 `setPrimary:true`(`POST /api/reviews/model/:id/approve`) 或显式调用 `POST /api/models/:id/primary`(仅 `published`,否则 409)时设置。 ## 7. 每任务的执行步骤 @@ -186,7 +186,7 @@ DB_HOST DB_PORT DB_USER DB_PASS DB_NAME DB_SYNCHRONIZE=false PORT=3000 # 软件 API 端口(客户端调用,不含管理页面) ADMIN_PORT=3001 # 管理端端口(默认 PORT + 1;等于 PORT 为单端口兼容模式) -HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.0.0.0) +HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.0.0.0) ADMIN_HOST=0.0.0.0 # 可选,管理端监听地址,默认跟随 HOST STORAGE_DIR=/data/storage UPLOAD_MAX_BYTES=536870912 @@ -202,10 +202,10 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本 ## 9. 双端口约定(软件 API 端口 / 管理端端口) -- **软件 API 端口**:`PORT`,默认 **3000**。给 Windows/Godot 客户端与 Android App - 调用,只提供 `/api`、`/health`、`/static`、`/contracts`;**不提供管理页面**, +- **软件 API 端口**: `PORT`,默认 **3000**。给 Windows/Godot 客户端与 Android App + 调用,只提供 `/api`、`/health`、`/static`、`/contracts`; **不提供管理页面**, `/`、`/songs` 等一律 404(有意设计,避免管理页暴露在客户端调用的端口上)。 -- **管理端端口**:`ADMIN_PORT`,默认 `PORT + 1`(即 **3001**)。给浏览器访问, +- **管理端端口**: `ADMIN_PORT`,默认 `PORT + 1`(即 **3001**)。给浏览器访问, 提供管理页面(含 SPA 深链接回退)与上面全部接口。 - `ADMIN_HOST` 可选,默认跟随 `HOST`;可用它把管理端限制在本机或内网。 - `ADMIN_PORT === PORT` 为**单端口兼容模式**:管理页面回到 API 端口,日志提示 @@ -215,7 +215,7 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本 - 反向代理按用途分别转发两个端口;`/health` 两个端口都提供,可用于存活探测。 细则见 `deploy/proxy/README.md` 与 `tasks/010-deploy.md`。 -## 10. VMD 动作通路(MikuMikuDance) +## 10. VMD 动作通路(MikuMikuDance) ``` .vmd ─▶ vmd-parser.ts ─▶ mmd-skeleton.ts ─▶ mmd-pose.ts ─▶ retarget.ts @@ -227,16 +227,16 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本 - VMD 二进制:30 字节头 + 20 字节 Shift-JIS 模型名 + 111 字节/条的骨骼关键帧 + morph 段; 只有旋转(少数骨骼带位移)、不含骨骼长度,因此必须靠 `mmd-skeleton.ts` 的标准骨架 (静止比例从参考 PMX 实测)做 FK。 -- **腿部驱动自动判定**:`足IK` 有非零位移 -> 两骨骼解析 IK(膝盖朝角色正面弯, +- **腿部驱动自动判定**: `足IK` 有非零位移 -> 两骨骼解析 IK(膝盖朝角色正面弯, 不可达时收敛为伸直);否则用 FK 旋转。部分动作数据把腿烘焙成 FK。 -- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反) +- **手性**: MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反) 后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored` 下「膝盖朝前」占 94.1%,不转换仅 5.9%。 - `worldFramesToKeypointFrames()` 为 BVH / VMD 共用:骨盆归零 / 自适应缩放 / 图像空间投影 / 9 个关节角完全一致。 - 诊断工具:`node scripts/inspect-vmd.mjs 动作.vmd [--frames N] [--json out.json]` (打印头部/模型名/帧数/轨道数、未识别骨骼、IK 还是 FK、关键点序列异常统计、膝肘角分布)。 -- 契约 `contracts/model-formats.md` 2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。 +- 契约 `contracts/model-formats.md` 2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。 ## 11. 模型格式策略、转换、分析与审核 @@ -252,7 +252,7 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 ├─ 2) 失败 ->.gltf -> gltf-packer.ts 打包回 GLB ├─ 3) MODEL_CONVERT_FORMAT=vrm -> vrm-writer.ts 补 VRMC_vrm └─ 每步 assimp info 回读校验 - ─▶ 入库(status=pending) + ─▶ 入库(status=pending) ─▶ model-inspector.ts 分析 -> models.stats ─▶ model-thumbnail.ts 缩略图 -> models.thumbnail_asset_id ``` @@ -266,7 +266,7 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 回读校验 -> 回退。 - `models/gltf-packer.ts`:把 Assimp 导出的分离 `.gltf` + `.bin` + 贴图自行打包成 GLB。 - `models/glb-textures.ts`:把 GLB 里 Assimp 写成外部 URI 的贴图内嵌进 BIN chunk。 - - `models/vrm-writer.ts`:GLB -> VRM 1.0,只改 JSON chunk 写 `extensions.VRMC_vrm`, + - `models/vrm-writer.ts`: GLB -> VRM 1.0,只改 JSON chunk 写 `extensions.VRMC_vrm`, 不做任何几何/材质损失。 - `models/model-inspector.ts`:解析 GLB/VRM,算面数 / 顶点 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性(缺哪些必需骨骼)/ warnings。 @@ -274,20 +274,20 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 - `models/default-model-source.ts`:渲染 / 写入 `src/config/default-model.ts`。 - `config/default-model.ts`:默认模型种子(审核 / CLI 写入,运行期只读)。 - **压缩包自动搜索**:优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内 - 按 `MODEL_CONVERT_PMX` 指名字 -> 目录最浅 -> 文件最大。命中 glTF 系格式时**直接导入**, + 按 `MODEL_CONVERT_PMX` 指名字 -> 目录最浅 -> 文件最大。命中 glTF 系格式时**直接导入**, 只有 `.pmx`/`.fbx` 才转换。压缩包原件存为 `model-archive` 资产。 -- **转换与回退**:GLB 直出失败 -> glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB - 之上补 `VRMC_vrm`(humanoid 不全则**回退交付 GLB**并把缺失骨骼写进 `conversion_note`)。 +- **转换与回退**: GLB 直出失败 -> glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB + 之上补 `VRMC_vrm`(humanoid 不全则**回退交付 GLB**并把缺失骨骼写进 `conversion_note`)。 **每一步产物都必须用 `assimp info` 回读校验**:实测 Assimp 会"写出损坏文件" - (FBX 导出退出码 11、回读报 `FBX-DOM no Objects dictionary found`),只看退出码 + (FBX 导出退出码 11、回读报 `FBX-DOM no Objects dictionary found`),只看退出码 会把坏文件入库。 -- **入库后自动**:`inspectModelFile()` 分析写入 `models.stats` +- **入库后自动**: `inspectModelFile()` 分析写入 `models.stats` (迁移 `AddModelStats1789460000000`,新增 `models.stats TEXT`); `renderModelThumbnail()` 写入 `models.thumbnail_asset_id` / `thumbnailUrl`。 **两者都是"尽力而为"**:失败只记 warning,不影响入库。 -- **状态与默认模型(硬性)**:上传**一律 `status=pending`**,`is_primary=false`, +- **状态与默认模型(硬性)**:上传**一律 `status=pending`**, `is_primary=false`, 不自动发布、不自动设为默认。默认模型只在 - ① 审核通过带 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)或 + ① 审核通过带 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)或 ② `POST /api/models/:id/primary`(仅 `published`,否则 **409**)时设置,此时写库 `is_primary` 并把身份写进源码种子。 `GET /api/catalog/export` 的 `defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` -> 源码种子 @@ -295,17 +295,17 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 -> 已发布且 is_primary 的 VRM -> 任意已发布 VRM -> id 最小的已发布模型。 - **溯源**:压缩包原件记入 `models.source_asset_id` (迁移 `AddModelConversionFields1789450000000`);`models.conversion_note` 记转换说明。 -- **失败**:包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**, +- **失败**:包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**, 不产生模型记录。 -- **文件名编码**:`common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart +- **文件名编码**: `common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`)。 -- 契约:`contracts/model-formats.md` 1.1、`contracts/data-models.md` 4.1; +- 契约:`contracts/model-formats.md` 1.1、`contracts/data-models.md` 4.1; DTO 新增 `conversionNote` / `fileSha256` / `stats` / `thumbnailUrl`。 ### 11.1 审核要求(模型) 管理端「审核」页对 `pending` 模型展示 **模型审核面板** -(`admin/src/components/ModelReviewPanel.tsx`): +(`admin/src/components/ModelReviewPanel.tsx`): - **指标**:体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画。 @@ -317,24 +317,24 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 - **通过**:可勾选「同时设为全局默认模型」-> 请求体 `setPrimary:true`。 - **驳回**:必须填写审核意见(400)。 - 实测:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200、 - `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`; + `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`; 重建后 `defaultModelId` 由 VRM 变为 **22**。 -### 11.2 实测数据(v0.2.0) +### 11.2 实测数据(v0.2.0) - 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 -> **HTTP 201 / 0.86s**、`format=glb`、 - `status=pending`、`conversionNote=Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`; + `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 骨名无法映射)。 -- 直接上传 `.glb`:**HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`, + maxBoneDepth=23`, `missingRequiredBones` 为全部 16 根(日文 MMD 骨名无法映射)。 +- 直接上传 `.glb`: **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`, 入库文件 sha256 与源文件**一致**。 - 缩略图:512x512 PNG / 15,043 字节,纯 TS 光栅化(含蒙皮),目视确认为居中的 - T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。 -- 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM"); + T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。 +- 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM"); 列表页显示 40px 缩略图。 - 测试:`npx vitest run` -> **10 个文件 / 168 项通过** - (`model-inspector` 25、`model-thumbnail` 8、`admin-bone-filter` 8、 + (`model-inspector` 25、`model-thumbnail` 8、`admin-bone-filter` 8、 `model-archive` 24、`mmd-vmd` 31、`preprocess` 25、`init-env` 16、 `admin-port` 16、`access-log` 9、`vrm-inspector` 6)。 @@ -346,22 +346,22 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 (口令只经环境变量传递、失败仅告警);只替换/追加这 8 个键、保留模板注释, 写完 `chmod 600` 并回读校验;结尾打印摘要(口令 `***`)与变更列表。 - 开关:`--force` / `--check` / `-y|--yes|--non-interactive` / `-i|--interactive` / - `--skip-db-check` / `--dir` / `--env-file` / `--example` / `--timeout` / `-h`; + `--skip-db-check` / `--dir` / `--env-file` / `--example` / `--timeout` / `-h`; 环境变量 `OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` / `OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` / `OPENDANCE_PROMPT_TIMEOUT`。 -- **非交互绝不挂住**:stdin 非 TTY 且是 ` +- **非交互绝不挂住**: stdin 非 TTY 且是 ` 只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。 - 接入点:`deploy/install.sh` 缺 `deploy/.env` 时先调用 `init-env.sh` 引导, 失败才 die(若 `$APP_DIR/deploy/.env` 已存在则复用,保持原语义)。 -- **监听地址默认 `0.0.0.0`**:`configuration.ts` 的 - `bindHost = process.env.HOST?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST` +- **监听地址默认 `0.0.0.0`**: `configuration.ts` 的 + `bindHost = process.env.HOST?? '0.0.0.0'`, `adminHost` 默认跟随 `HOST` (同样回落 `0.0.0.0`);`deploy/.env.example` 与 `web-server/.env.example` - 均为 `HOST=0.0.0.0`;`install.sh` 的 `BIND_HOST="${HOST:-0.0.0.0}"`, + 均为 `HOST=0.0.0.0`; `install.sh` 的 `BIND_HOST="${HOST:-0.0.0.0}"`, `init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。 - (`install.sh` 的 `HEALTH_HOST`/`API_HOST` 是**本机健康检查**用的回环地址, + (`install.sh` 的 `HEALTH_HOST`/`API_HOST` 是**本机健康检查**用的回环地址, 与对外监听的默认值无关。) -- 测试 `test/init-env.spec.ts`(16 项)。 +- 测试 `test/init-env.spec.ts`(16 项)。 ## 13. 上下文超标时的拆分策略 diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index b54ab22..8074d44 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -95,7 +95,7 @@ ## 14. class-validator / class-transformer - 许可证:MIT -- 源链接:https://github.com/typestack/class-validator , +- 源链接:https://github.com/typestack/class-validator , https://github.com/typestack/class-transformer - 使用端:Web 服务端(请求校验与转换) diff --git a/_probe/ambiguity.txt b/_probe/ambiguity.txt deleted file mode 100644 index 6ccf80b..0000000 --- a/_probe/ambiguity.txt +++ /dev/null @@ -1,16 +0,0 @@ -P01 fullwidth comma after ascii: value,next -P02 fullwidth comma between cjk: 值,下 -P03 fullwidth colon after ascii: key:value -P04 fullwidth colon between cjk: 键:值 -P05 fullwidth semicolon after ascii: a;b -P06 fullwidth semicolon between cjk: 甲;乙 -P07 fullwidth question after ascii: ok? -P08 fullwidth question between cjk: 好?吧 -P09 fullwidth exclam after ascii: ok! -P10 fullwidth lparen pair ascii inside: x(API 29)y -P11 fullwidth lparen pair cjk inside: 值(括号)下 -P12 fullwidth paren pair with ascii right after close: 地址(默认 http://127.0.0.1:3000),后 -P13 ascii paren pair: x(API 29)y -P14 ascii then fullwidth comma: value,下 -P15 fullwidth dot after ascii: ok。 -P16 fullwidth ideographic comma after ascii: a、b diff --git a/_probe/chunk-0.txt b/_probe/chunk-0.txt deleted file mode 100644 index 01d864f..0000000 --- a/_probe/chunk-0.txt +++ /dev/null @@ -1,40 +0,0 @@ -================================================================ -OpenDance Studio -- Android 辅助 App v0.1.0 使用说明 -================================================================ - -手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过 -WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评分。 - -隐私:画面不会离开手机。通过网络发送的只有 33 个关键点坐标。 - --- -一、安装 --- - -方式一(推荐):直接安装随附的 app-release.apk - - 在手机上允许「安装未知来源应用」 - - 点击 APK 安装 - -方式二:用 adb 安装 - adb install -r app-release.apk - -要求:Android 10(API 29)或更高版本。 - --- -二、初次部署(从零跑起来) --- - -本节面向第一次部署的人,给出从零到跑通的最短路径。 -下文地址、路径、口令一律是占位符,请替换成你自己的值,且不要提交进仓库。 - -1) 前置条件 - - JDK 17 或更高 AGP 8.7.3 要求,构建脚本会校验主版本 >= 17 - Gradle 8.9 仓库自带 gradlew,本机无需另装 - Android SDK platforms;android-34 与 build-tools;34.0.0 - compileSdk 34 / minSdk 29(Android 10+)/ targetSdk 34 - 设备 需要真机(Android 10 及以上、带摄像头) - - 真机与模拟器: - release 包只含 arm64-v8a + armeabi-v7a,装不到 x86/x86_64 模拟器; - debug 包含 x86/x86_64,可装进模拟器,但相机是虚拟场景,姿态识别 \ No newline at end of file diff --git a/_probe/chunk-1.txt b/_probe/chunk-1.txt deleted file mode 100644 index eb7af49..0000000 --- a/_probe/chunk-1.txt +++ /dev/null @@ -1,41 +0,0 @@ - 效果不能代表真机,不能作为验收依据。联调验收请用真机。 - - 权限:相机权限为运行时授予;INTERNET / ACCESS_NETWORK_STATE / - ACCESS_WIFI_STATE 为普通权限,安装时自动授予,用于推流与局域网扫描。 - -2) 获取代码与本地配置 - - git clone https:////opendance-android.git - cd opendance-android - - local.properties 是本地文件(已在 .gitignore 中,不入库),内容: - sdk.dir= - - build/build-apk.sh 在缺失时会依据 ANDROID_HOME / ANDROID_SDK_ROOT 自动生成。 - - 签名材料不入库:仓库只有生成脚本,没有私钥。首次发布前执行 - bash build/gen-keystore.sh - 生成 keystore/opendance-release.jks 与 keystore.properties(含 storeFile、 - keyAlias 与两个口令字段)。请自行备份,且不要提交。 - 取值优先级:keystore.properties -> 环境变量(KEYSTORE_PATH / - KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD)-> 回退 debug 签名 - (仅供 CI 产出已签名 APK,正式发布必须自备 keystore)。 - -3) 首次构建 - - export JAVA_HOME= - export ANDROID_HOME= - - bash build/build-apk.sh debug # 仅 debug - bash build/build-apk.sh release # 仅 release - bash build/build-apk.sh all # 两者都构建(含产物校验) - - 或直接用 Gradle: - ./gradlew assembleDebug - ./gradlew assembleRelease - - 产物(固定路径): - app/build/outputs/apk/debug/app-debug.apk - app/build/outputs/apk/release/app-release.apk - - 发布整包(APK + THIRD_PARTY.md + README.txt + contracts/): \ No newline at end of file diff --git a/_probe/chunk-2.txt b/_probe/chunk-2.txt deleted file mode 100644 index 353ee8f..0000000 --- a/_probe/chunk-2.txt +++ /dev/null @@ -1,40 +0,0 @@ - bash build/package-release.sh -> dist-release/ - -4) 安装到手机 - - adb install -r app/build/outputs/apk/debug/app-debug.apk - adb install -r app/build/outputs/apk/release/app-release.apk - - 也可把 APK 拷到手机,允许「安装未知来源应用」后点击安装。 - 首次进入运行页会申请相机权限;拒绝后可在运行页点「授予相机权限」 - 重新申请,或到系统设置里手动开启。 - -5) 配置与联通 - - 本 App 只连接 Windows 客户端,不访问 Web 服务端。 - 地址来自 AppSettings.webSocketUrl():ws://:/pose - 子协议 opendance.pose.v1。 - - 连接页字段与默认值(见 data/AppSettings.kt): - IP 地址 host 默认是 RFC 5737 文档网段占位地址 - (源码为 192.0.2.100,非真实主机) - 端口 port 8765 - 目标帧率 targetFps 30(5~60) - 分辨率 resolution 640x480(可选 480x360 / 1280x720) - 镜像画面 mirror 开 - 仅上半身模式 upperBodyOnly 关 - - 填写示例(占位符,请替换成你电脑的局域网 IP): - IP 地址:192.0.2.10 - 端口: 8765 - - 不想手填可点「扫描局域网」:App 由本机 WiFi IPv4 推导 /24 网段, - 以 32 并发、单机 300ms 超时探测开放 8765 的主机(约 2~3 秒), - 按延迟升序展示,点候选即填入。也可先点「测试连接」确认端口可达。 - -6) 第一次跑通 - - 手机与电脑连同一局域网 -> 电脑启动 Windows 客户端(监听 8765)-> - App 填地址并测试连接 -> 点「开始推流」并授予相机权限 -> 站到画面中央。 - - 成功标志: \ No newline at end of file diff --git a/_probe/chunk-3.txt b/_probe/chunk-3.txt deleted file mode 100644 index b1f8da9..0000000 --- a/_probe/chunk-3.txt +++ /dev/null @@ -1,41 +0,0 @@ - App 运行页 「连接状态」为「已连接」,「目标」显示 ws://:<端口>/pose - App 运行页 服务端返回 welcome 后出现「会话」ID - App 运行页 「已发送」持续增长,「发送帧率」「识别帧率」大于 0 - App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低) - Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来 - -7) 自检清单 - - ./gradlew testDebugUnitTest - 预期:BUILD SUCCESSFUL,全部用例通过 - 报告:app/build/reports/tests/testDebugUnitTest/index.html - - bash build/build-apk.sh all - 预期:debug 与 release 均显示 "OK ... APK",release 显示 - "已签名(APK Signature Scheme v2)" - - $ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk - 预期:退出码 0(无输出即通过) - - adb install -r app/build/outputs/apk/debug/app-debug.apk - 预期:Success - -8) 常见问题 - - Q: 相机黑屏 / 一直提示需要相机权限 - A: 权限被拒绝或已被永久拒绝,或相机被其他应用占用。在运行页点 - 「授予相机权限」;不再弹窗时到系统「设置 -> 应用 -> OpenDance -> 权限」 - 手动开启相机,并关闭占用相机的其它 App。 - - Q: 连不上 Windows 客户端 / 扫描不到主机 - A: 核对 IP 与端口、确认客户端已启动并监听 8765、在 Windows 防火墙放行 - OpenDance 的 TCP 8765 入站、确认手机与电脑同一 WiFi 且路由器未开 AP 隔离。 - - Q: 点「开始推流」闪退(或提示 MediaPipe 初始化失败) - A: MediaPipe 原生库加载 / 静态初始化失败抛出的是 Error 子类 - (UnsatisfiedLinkError、ExceptionInInitializerError),不是 Exception; - 常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器) - 或模型资源缺失。处理:改用与设备 ABI 匹配的包(真机用 release、 - 模拟器调试用 debug);确认 APK 内含 assets/pose_landmarker_lite.task。 - 仓库已在 PoseDetector.initialize() 中 catch Throwable 兜底并把原因显示在 - 运行页;CrashLogger 会把未捕获堆栈写入应用私有目录,并在连接页展示, \ No newline at end of file diff --git a/_probe/chunk-4.txt b/_probe/chunk-4.txt deleted file mode 100644 index e1c98ef..0000000 --- a/_probe/chunk-4.txt +++ /dev/null @@ -1,41 +0,0 @@ - 可截图反馈,或用 adb logcat -s PoseDetector 查看。 - --- -三、快速开始 --- - -1. 在 Windows 电脑上启动 OpenDance Studio 客户端 - (Windows 客户端会自动监听 8765 端口) - -2. 确保手机与电脑连在同一个 WiFi - -3. 打开本应用,进入「连接设置」页 - -4. 填写电脑的 IP 地址,或直接点「扫描局域网」自动发现 - (扫描会探测局域网内开放 8765 端口的主机,约 2~3 秒) - -5. 可点「测试连接」确认端口可达 - -6. 按需调整推流参数(见下) - -7. 点「开始推流」,授予相机权限 - -8. 站到画面中央,屏幕上会出现骨架叠加: - - 黄色圆点 = 识别良好的关节 - - 红色圆点/红色连线 = 置信度低(光线不足或被遮挡) - -9. 在 Windows 客户端上确认已连接,即可开始跟练 - --- -四、推流参数 --- - -分辨率 - 低(480x360) 最省电,适合老机型 - 中(640x480) 推荐,识别精度与功耗平衡 - 高(1280x720) 最清晰,但推理更慢、更耗电 - -目标帧率 - 默认 30 FPS。低端机若「识别帧率」明显低于目标值,可降到 20 或 15。 - -镜像画面 \ No newline at end of file diff --git a/_probe/chunk-5.txt b/_probe/chunk-5.txt deleted file mode 100644 index 86ed013..0000000 --- a/_probe/chunk-5.txt +++ /dev/null @@ -1,40 +0,0 @@ - 默认开启。前置摄像头的常见用法。 - 注意:本应用发送的是未镜像的原始坐标,是否镜像由帧头 flags.bit0 声明, - Windows 客户端据此正确还原左右。因此无论开关如何,动作方向都不会错。 - -仅上半身模式 - 下半身关键点置信度置 0(显示为红点),Windows 端会插值。 - 带宽或算力不足时开启可明显降负载。 - --- -五、运行页状态说明 --- - -连接状态 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开 -目标 正在连接的 WebSocket 地址 -会话 服务端返回的会话 ID -已发送 累计发送帧数 -已丢弃 因推理或连接跟不上而丢弃的帧数(>0 说明设备吃力) -发送帧率 实际推流帧率 -识别帧率 实际推理帧率 -推理耗时 单帧 MediaPipe 推理毫秒数 -人体识别 已识别 / 未识别到人体 -平均置信度 < 70% 时转黄,提示改善光线或减少遮挡 -最近错误 连接或推理的错误原因 - -网络中断时会自动重连(1s -> 2s -> 4s... 最长 15s 退避), -无需手动操作。 - --- -六、常见问题 --- - -Q: 扫描不到电脑 -A: 1) 确认手机与电脑在同一 WiFi(不是手机热点) - 2) 确认 Windows 客户端已启动并正在监听 8765 - 3) 在 Windows 防火墙放行 OpenDance.exe 的 TCP 8765 入站 - 4) 部分路由器开启了「AP 隔离」,会阻断设备互访,需在路由器关闭 - -Q: 显示「已连接」但电脑端没有画面 -A: 查看运行页的「已发送」是否在增长。若为 0,说明相机或推理没出帧; - 若「人体识别」一直是「未识别到人体」,请站到画面中央并保证光线充足。 \ No newline at end of file diff --git a/_probe/chunk-6.txt b/_probe/chunk-6.txt deleted file mode 100644 index ef3cd2a..0000000 --- a/_probe/chunk-6.txt +++ /dev/null @@ -1,40 +0,0 @@ - -Q: 帧率很低 / 已丢弃帧数持续增长 -A: 依次尝试:降低分辨率 -> 降低目标帧率 -> 开启「仅上半身模式」-> 关闭其他后台应用。 - -Q: 关键点抖动明显 -A: 正常现象,Windows 端有 DTW 带状对齐与平滑处理,不要求逐帧完全一致。 - 改善光照可显著提高稳定性。 - -Q: 应用退到后台会怎样? -A: 返回键或离开运行页会立即停止推流并释放相机,不会在后台占用摄像头。 - --- -七、从源码构建 --- - -环境要求: - JDK 17 AGP 8.7.3 要求(JDK 25 未经测试) - Android SDK platforms;android-34 与 build-tools;34.0.0 - Gradle 8.9 已随 gradlew 提供 - -构建: - - export JAVA_HOME=/path/to/jdk17 - export ANDROID_HOME=/path/to/android-sdk - - # 生成发布签名密钥库(首次;文件不会入库,请自行备份) - bash build/gen-keystore.sh - - # 构建并校验产物 - bash build/build-apk.sh all - - # 一键验证(单元测试 -> 构建 -> 产物校验) - bash build/verify.sh - -产物: - app/build/outputs/apk/debug/app-debug.apk - app/build/outputs/apk/release/app-release.apk - -Android Studio 路径: - 打开本目录 -> Build -> Generate Signed Bundle / APK -> APK -> 选择 keystore -> release diff --git a/_probe/chunk-7.txt b/_probe/chunk-7.txt deleted file mode 100644 index ad4a00b..0000000 --- a/_probe/chunk-7.txt +++ /dev/null @@ -1,40 +0,0 @@ --- -八、测试 --- - - ./gradlew testDebugUnitTest - -30 项单元测试(JVM,无需设备),核心是金标准字节向量测试: -用 opendance-web 已通过验收的 TypeScript 实现产出的 239 字节向量, -断言本端 Kotlin 实现逐位相同 -- 这是三端契约一致性的硬证据。 - -测试报告:app/build/reports/tests/testDebugUnitTest/index.html - --- -九、目录结构 --- - - settings.gradle.kts / build.gradle.kts 工程与插件配置 - gradle/wrapper/ Gradle 8.9 wrapper - project.yaml 任务清单与状态 - DSH.md 本项目 DSH 执行约束 - tasks/ 单任务说明书(T-AND-001 ~ 009) - contracts/ 只读契约快照(来源 opendance-web v0.1.0) - app/src/main/java/com/opendance/studio/ - MainActivity.kt 三页导航 - model/ 239 字节帧编解码 + 33 点拓扑 - camera/ CameraX 采集 - pose/ MediaPipe 端侧推理 - net/ WebSocket 推流 + 主机发现 - data/ 设置 - ui/ 连接页 / 运行页 / 关于页 - app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型 - build/ 打包与验证脚本 - --- -十、许可 --- - -本应用代码以 MIT 许可发布。第三方开源项目声明见 THIRD_PARTY.md -(应用内「关于」页同样可查看)。 -未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑。 diff --git a/contracts/data-models.md b/contracts/data-models.md index f377aea..be11338 100644 --- a/contracts/data-models.md +++ b/contracts/data-models.md @@ -5,7 +5,7 @@ Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视 ## 1. 通用约定 -- 所有资源返回 `id: number`(BIGINT,超出 2^53 前不会发生)。 +- 所有资源返回 `id: number`(BIGINT,超出 2^53 前不会发生)。 - 所有时间为 ISO 8601 UTC 字符串,例:`2026-09-14T10:00:00.000Z`。 - 列表接口统一分页响应: @@ -117,7 +117,7 @@ rejected 已驳回 ``` 0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布 1. 源码内写死的默认模型种子(web-server/src/config/default-model.ts 的 - DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型, + DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型, 换库/重建库后仍然有效;没有压缩包时退回按产物 sha256,最后按名称兜底。 ⚠ 不能用产物 sha256 当主键:实测 Assimp 的 GLB 导出不是字节级确定的, 同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 有十几字节浮动) @@ -145,7 +145,7 @@ rejected 已驳回 | `comment` | string\|null | 审核意见 | | `createdAt` | string | | -## 6. `CatalogBundle`(客户端一次性拉取,`GET /api/catalog/export`) +## 6. `CatalogBundle`(客户端一次性拉取,`GET /api/catalog/export`) ```json { diff --git a/contracts/database.md b/contracts/database.md index 161f404..53762cd 100644 --- a/contracts/database.md +++ b/contracts/database.md @@ -3,11 +3,11 @@ ## 环境 - 类型:MySQL 兼容实例(外部提供,含 MariaDB 10.5+),TypeORM - 使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+ + 使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+ 共同子集内,不使用 MySQL 8 专有语法(窗口函数 CTE 递归除外)。 -- 数据库名:由部署方提供,通过 `DB_NAME` 注入(本文档示例统一用 `opendance`) +- 数据库名:由部署方提供,通过 `DB_NAME` 注入(本文档示例统一用 `opendance`) - 字符集:`utf8mb4` / `utf8mb4_general_ci`(沿用现有实例默认排序规则) -- 迁移工具:TypeORM migration,**不使用 `DB_SYNCHRONIZE`** +- 迁移工具:TypeORM migration, **不使用 `DB_SYNCHRONIZE`** ## 连接信息(由部署方提供) @@ -25,10 +25,10 @@ DB_LOGGING=false ## 约定 -- 所有表名使用 `snake_case`,复数形式(`songs` / `motions` / `models`) +- 所有表名使用 `snake_case`,复数形式(`songs` / `motions` / `models`) - 主键统一为 `BIGINT AUTO_INCREMENT`,字段名 `id` -- 时间字段统一 `DATETIME`,`created_at` / `updated_at`,`updated_at` 由应用层维护 -- 软删除统一使用 `deleted_at DATETIME NULL`(`@DeleteDateColumn`) +- 时间字段统一 `DATETIME`, `created_at` / `updated_at`, `updated_at` 由应用层维护 +- 软删除统一使用 `deleted_at DATETIME NULL`(`@DeleteDateColumn`) - 布尔字段使用 `TINYINT(1)` - 来源(出处)字段为 5 元组,任何资产入库必须齐备: `source_type` / `source_name` / `source_url` / `license` / `author` @@ -36,24 +36,24 @@ DB_LOGGING=false - 每个 migration 必须可逆(提供 `down` 方法) - 迁移文件名格式:`-.ts` -## 增量迁移(v0.2.0 / v0.3.0) +## 增量迁移(v0.2.0 / v0.3.0) | 迁移 | 变更 | |--|--| -| `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md 1.1) | +| `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md 1.1) | | `AddModelStats` | `models` 增加 `stats TEXT NULL`(模型分析结果:面数、骨骼树、humanoid 覆盖、可驱动性),供审核界面展示 | -## 表清单(v0.1.0) +## 表清单(v0.1.0) | 表 | 用途 | 任务 | |--|--|--| | `songs` | 曲目元数据 | T-WEB-003 | | `assets` | 上传文件元数据(去重、sha256、物理路径) | T-WEB-004 | -| `motions` | 动作数据(BVH/FBX...)+ 来源 + 预处理产物 | T-WEB-004 / 006 | +| `motions` | 动作数据(BVH/FBX...)+ 来源 + 预处理产物 | T-WEB-004 / 006 | | `models` | 角色模型(VRM 优先)+ 来源 + 压缩包转换溯源 | T-WEB-005 | | `review_records` | 上传审核流水 | T-WEB-007 | -## 状态机(`status` 列,v0.1.0) +## 状态机(`status` 列,v0.1.0) ``` draft ──submit──▶ pending ──approve──▶ published diff --git a/contracts/model-formats.md b/contracts/model-formats.md index 4155755..b6a50c2 100644 --- a/contracts/model-formats.md +++ b/contracts/model-formats.md @@ -1,21 +1,21 @@ # contracts/model-formats.md - 模型与动作格式契约 v0.4.0 -## 1. 角色模型(models) +## 1. 角色模型(models) **系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。 | 格式 | 扩展名 | 支持级别 | 服务端行为 | |--|--|--|--| -| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 | -| VRM | `.vrm` | **主力(P0)** | **直接导入**;= GLB + VRM 扩展(humanoid / 表情 / springBone / meta) | -| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) | +| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 | +| VRM | `.vrm` | **主力(P0)** | **直接导入**; = GLB + VRM 扩展(humanoid / 表情 / springBone / meta) | +| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) | | MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 1.1 | | MMD 模型 | `.pmx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM);单文件上传无贴图 | -| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) | +| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) | ### 1.1 上传与转换策略 -**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**: +**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**: **优先 GLB,其次 VRM**。 #### 单文件上传 @@ -28,7 +28,7 @@ #### 压缩包上传(自动搜索) -不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**: +不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**: ``` .vrm ->.glb ->.gltf (直接导入,优先) @@ -38,7 +38,7 @@ 同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。 压缩包原件以 `model-archive` 资产留存并写入 `models.source_asset_id`。 -#### 转换与校验(PMX / FBX) +#### 转换与校验(PMX / FBX) | 步骤 | 规则 | |--|--| @@ -46,7 +46,7 @@ | 2. glTF 打包 | 上一步失败时 `assimp export ->.gltf`,由 `gltf-packer.ts` 把 `.gltf` + `.bin` + 贴图自行合成 GLB | | 3. VRM 封装 | `MODEL_CONVERT_FORMAT=vrm` 时,在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼来自骨骼名映射,见 `model-inspector.ts` 的候选表,与 Godot 客户端一致);**humanoid 不全则回退交付 GLB**,缺失骨骼写进 `conversion_note` | | 回读校验 | 每一步产物都要 `assimp info` 回读。Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库 | -| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`) | +| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`) | | 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**,不产生模型记录 | #### 入库后(自动) @@ -63,14 +63,14 @@ | 项 | 规则 | |--|--| | 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 | -| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE | +| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE | **VRM 优先**的选取顺序见 `data-models.md` 4.1。 -VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或 -`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。 +VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或 +`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。 -## 2. 动作数据(motions) +## 2. 动作数据(motions) | 格式 | 扩展名 | 预处理支持 | 说明 | |--|--|--|--| @@ -82,7 +82,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x) ### 2.1 预处理产物(冻结) -- 格式:NDJSON(`application/x-ndjson`),每行一帧,UTF-8 无 BOM +- 格式:NDJSON(`application/x-ndjson`),每行一帧,UTF-8 无 BOM - 一行结构: ```json @@ -93,9 +93,9 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x) - `angles` 为 `skeleton-topology.md` 5 定义的 9 个关节角余弦,顺序固定: `[shoulder_L, shoulder_R, elbow_L, elbow_R, hip_L, hip_R, knee_L, knee_R, spine]` - 帧率:保留源帧率;`fps` 写入 `motions.fps`,缺省 30 -- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**, +- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**, 再投影到 MediaPipe 归一化图像空间: - `x_img = 0.5 + x_world * scale`,`y_img = 0.5 - y_world * scale`,`z_img = -z_world * scale`, + `x_img = 0.5 + x_world * scale`, `y_img = 0.5 - y_world * scale`, `z_img = -z_world * scale`, `scale = 0.35 / max(|x|,|y|,|z|)`(按整段动作的最大范围自适应,保证不出画) - 缺失关节(BVH 中不存在对应骨骼):`confidence = 0.0`,坐标沿用最近可用父节点 @@ -122,18 +122,18 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x) 未映射到的点按 `skeleton-topology.md` 5 的层级由父节点推算, 并置 `confidence` 降低(`0.5`),表示估算而非实测。 -### 2.3 VMD(MikuMikuDance)解析约定 +### 2.3 VMD(MikuMikuDance)解析约定 -VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节, -Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**:15 字节骨骼名、uint32 帧号、 +VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节, +Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**: 15 字节骨骼名、uint32 帧号、 float32x3 位移、float32x4 旋转四元数、64 字节贝塞尔插值参数)+ morph / 相机 / 灯光 / 自阴影 / IK / 可见性段落。**VMD 不含骨骼长度**,只有旋转(少数骨骼带位移), 因此服务端必须自带一套标准 MMD 骨架才能做 FK。 处理链路:`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts` -(标准骨架)-> `mmd-pose.ts`(FK + 腿部 IK)-> `retarget.ts` 的 +(标准骨架)-> `mmd-pose.ts`(FK + 腿部 IK)-> `retarget.ts` 的 `worldFramesToKeypointFrames()`(与 BVH 完全共用骨盆归零 / 自适应缩放 / 图像空间投影 / -9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。 +9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。 | MediaPipe 点 | MMD 标准骨骼 | 说明 | |--|--|--| @@ -149,11 +149,11 @@ float32x3 位移、float32x4 旋转四元数、64 字节贝塞尔插值参数) 骨骼名按 `mmd-skeleton.ts` 的别名表归一(含全角 `IK` / 半角 `IK` 两种写法); 未纳入标准骨架的骨骼(裙摆、物理、表情等)被忽略但会计数并写入预处理日志。 -**腿部驱动判定**:`足IK` 轨道存在非零位移 -> 走 IK(用两骨骼解析 IK 把踝拉到 IK +**腿部驱动判定**: `足IK` 轨道存在非零位移 -> 走 IK(用两骨骼解析 IK 把踝拉到 IK 目标点,膝盖朝角色正面弯,不可达时收敛为伸直);否则走 FK(部分动作数据把腿烘焙成 FK 旋转)。两份示例文件分别命中两种路径。 -**坐标系**:MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色 +**坐标系**: MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色 左侧)。VMD 的四元数与位移统一做 Z 镜像(`(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反)后 送入标准骨架。该选择由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored` 下 膝盖朝前占比 94.1%,不转换时仅 5.9%(见 `test/mmd-vmd.spec.ts`)。 @@ -167,15 +167,15 @@ FK 旋转)。两份示例文件分别命中两种路径。 | 项 | 值 | |--|--| -| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) | +| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) | | 允许扩展名 | 上表全部;其他一律 422 | | 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` | | 去重 | 相同 sha256 复用同一 `assets` 记录,不重复落盘 | | 文件名 | 服务端不使用原始文件名做路径,只存 `assets.original_name` | -## 4. 开源依赖与许可证(引用 `open-source-credits.md`) +## 4. 开源依赖与许可证(引用 `open-source-credits.md`) -- `bvh`(MIT)- BVH 解析参考实现 -- `Assimp`(BSD-3-Clause)- FBX/glTF 转换,**可选外部依赖** -- `@pixiv/three-vrm`(MIT)- 管理端 VRM 预览 -- `three`(MIT)- 管理端 3D 预览 +- `bvh`(MIT)- BVH 解析参考实现 +- `Assimp`(BSD-3-Clause)- FBX/glTF 转换,**可选外部依赖** +- `@pixiv/three-vrm`(MIT)- 管理端 VRM 预览 +- `three`(MIT)- 管理端 3D 预览 diff --git a/contracts/skeleton-topology.md b/contracts/skeleton-topology.md index 2fd6624..0130e2d 100644 --- a/contracts/skeleton-topology.md +++ b/contracts/skeleton-topology.md @@ -47,7 +47,7 @@ | 33 | neck | `midpoint(left_shoulder(11), right_shoulder(12))` | | 34 | pelvis | `midpoint(left_hip(23), right_hip(24))` | -## 3. 父节点表(`parent[i]`,-1 表示世界根) +## 3. 父节点表(`parent[i]`, -1 表示世界根) 对称树,便于骨骼重定向与关节角计算: @@ -91,16 +91,16 @@ ## 4. 坐标系 -- **归一化图像坐标**:`x, y ∈ [0, 1]`,原点左上角,`y` 向下;`x` 相对图像宽,`y` 相对图像高。 -- **深度**:`z` 与 `x` 同尺度,原点位于髋中心,**负值朝向相机**。 -- **镜像**:Android 前置摄像头默认输出镜像画面;是否镜像由帧头 `flags.bit0` 声明, - 接收端据此决定是否翻转 `x`(`x' = 1 - x`)。 -- **置信度**:`visibility ∈ [0, 1]`,落盘/传输为 `uint8`(`round(v * 255)`)。 +- **归一化图像坐标**: `x, y ∈ [0, 1]`,原点左上角,`y` 向下;`x` 相对图像宽,`y` 相对图像高。 +- **深度**: `z` 与 `x` 同尺度,原点位于髋中心,**负值朝向相机**。 +- **镜像**: Android 前置摄像头默认输出镜像画面;是否镜像由帧头 `flags.bit0` 声明, + 接收端据此决定是否翻转 `x`(`x' = 1 - x`)。 +- **置信度**: `visibility ∈ [0, 1]`,落盘/传输为 `uint8`(`round(v * 255)`)。 归一化比例统一使用 `32767`(见 `websocket-keypoints.md`)。 ## 5. 派生特征(DTW 输入,Windows 端实现,服务端预处理产物同构) -服务端 T-WEB-006 预处理产出的关键点序列与 Android 端输出的帧**结构完全一致**, +服务端 T-WEB-006 预处理产出的关键点序列与 Android 端输出的帧**结构完全一致**, 因此 Windows 端 DTW 引擎只有一条输入通路。 关节角特征(弧度,余弦值便于插值): diff --git a/contracts/websocket-keypoints.md b/contracts/websocket-keypoints.md index ce1ec53..f6ed088 100644 --- a/contracts/websocket-keypoints.md +++ b/contracts/websocket-keypoints.md @@ -10,17 +10,17 @@ ws://:8765/pose ``` - Windows 客户端监听,Android App 主动连接。 -- 子协议:`opendance.pose.v1`(`Sec-WebSocket-Protocol`) +- 子协议:`opendance.pose.v1`(`Sec-WebSocket-Protocol`) - 二进制帧传输关键点,文本帧传输控制消息(JSON)。 - 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。 ## 2. 二进制帧:239 字节(冻结) -**小端序(little-endian)**。 +**小端序(little-endian)**。 ``` 偏移 长度 字段 类型 说明 -0 2 magic u16 LE 固定 0x4F44(ASCII "OD") +0 2 magic u16 LE 固定 0x4F44(ASCII "OD") 2 1 version u8 固定 0x01 3 1 flags u8 bit0 = 镜像画面(mirrored) bit1 = 本帧含低置信度点(<0.5) @@ -32,7 +32,7 @@ ws://:8765/pose 合计 239 ``` -### 单个关键点(7 字节,`i` 为 0..32) +### 单个关键点(7 字节,`i` 为 0..32) ``` 偏移 长度 字段 类型 换算 @@ -43,7 +43,7 @@ ws://:8765/pose ``` - 点索引与坐标语义见 `skeleton-topology.md`。 -- 编码:`x = clamp(round(x_norm * 32767), -32768, 32767)`,`y`、`z` 同理。 +- 编码:`x = clamp(round(x_norm * 32767), -32768, 32767)`, `y`、`z` 同理。 - 低置信度点(`conf < 128`)坐标无意义,接收端必须插值,不得直接使用。 - `i16` 为有符号数:`0x8000` 表示 `-32768`。 @@ -68,7 +68,7 @@ func decode(buf: PackedByteArray) -> Dictionary: return {"flags": flags, "frame_id": frame_id, "points": pts} ``` -## 3. 文本控制帧(JSON,UTF-8) +## 3. 文本控制帧(JSON, UTF-8) ### 3.1 `hello` - Android -> Windows,连接后立即发送 diff --git a/deploy/proxy/README.md b/deploy/proxy/README.md index b5c31f9..5b36ae5 100644 --- a/deploy/proxy/README.md +++ b/deploy/proxy/README.md @@ -1,6 +1,6 @@ -# 反向代理说明(nginx / IIS) +# 反向代理说明(nginx / IIS) -> **职责边界**:本 Web 端**只提供业务服务**(NestJS API + 管理端页面), +> **职责边界**:本 Web 端**只提供业务服务**(NestJS API + 管理端页面), > **不部署、不配置反向代理**。反向代理由另外的服务或团队负责,本文档只提供配置说明。 > > 安装脚本 `deploy/install.sh` 不会安装或修改任何反代软件。 @@ -11,16 +11,16 @@ | 端口 | 环境变量 | 默认 | 给谁用 | 提供什么 | |--|--|--|--|--| -| 软件 API 端口 | `PORT` | `3000` | Windows/Godot 客户端、Android App | `/api`、`/health`、`/static`、`/contracts`;**不含管理页面**(未知路径 404) | +| 软件 API 端口 | `PORT` | `3000` | Windows/Godot 客户端、Android App | `/api`、`/health`、`/static`、`/contracts`; **不含管理页面**(未知路径 404) | | 管理端端口 | `ADMIN_PORT` | `3001` | 浏览器(管理员) | 管理页面 + 上面全部接口 | ``` -软件 API: http://<后端地址>:3000 +软件 API: http://<后端地址>:3000 管理端: http://<后端地址>:3001 ``` 两者都不需要在代理层托管静态文件,也不需要配置 SPA 回退(服务端已处理); -各用一条 `location /`(nginx)或全匹配规则(IIS)即可。 +各用一条 `location /`(nginx)或全匹配规则(IIS)即可。 **推荐做法**:把两个端口挂在两个不同的站点/域名上,便于分别做访问控制,例如 @@ -33,12 +33,12 @@ | 路径 | 内容 | 哪个端口提供 | |--|--|--| | `/`、`/assets/*` | 管理端页面与静态资源 | **仅管理端**;前端路由深链接(如 `/songs`)由服务端回退到 `index.html` | -| `/api/*` | REST API | 两个端口都提供(契约见 `contracts/rest-api.yaml`) | +| `/api/*` | REST API | 两个端口都提供(契约见 `contracts/rest-api.yaml`) | | `/health` | 健康检查 | 两个端口都提供;可用作反代的存活探测,数据库不可用时返回 **503** | | `/static/*` | 上传文件 | 两个端口都提供(模型 / 音频 / 关键点序列) | | `/contracts/*` | 契约快照 | 两个端口都提供,只读 | -> Windows 客户端与 Android 之间的**关键点 WebSocket 链路不经过本服务**, +> Windows 客户端与 Android 之间的**关键点 WebSocket 链路不经过本服务**, > 反代无需配置 WebSocket 转发。 > > `ADMIN_PORT` 设为与 `PORT` 相同时是**单端口兼容模式**:两个端口合一, @@ -58,7 +58,7 @@ ## 三、必须同步调整的两项 -1. **上传上限**:本服务默认允许 512 MiB(`UPLOAD_MAX_BYTES`)。 +1. **上传上限**:本服务默认允许 512 MiB(`UPLOAD_MAX_BYTES`)。 代理层默认值通常远小于此(nginx 1 MB、IIS 约 28.6 MB),不改会导致大模型上传失败。 2. **超时**:上传大文件与下载关键点序列可能较慢,代理的读写超时必须放大。 @@ -138,7 +138,7 @@ IIS 管理器 -> 选中服务器节点 -> Application Request Routing Cache - + @@ -181,10 +181,10 @@ curl -i http://127.0.0.1:3001/health # 2. 经反代可访问 curl -i http://<反代地址>/health -# 3. 管理端页面可打开(应返回 HTML) +# 3. 管理端页面可打开(应返回 HTML) curl -i http://<反代地址>/ | head -5 -# 4. 前端深链接可用(应返回同一份 HTML,而不是 404) +# 4. 前端深链接可用(应返回同一份 HTML,而不是 404) curl -i http://<反代地址>/songs | head -5 # 5. API 可用 @@ -196,7 +196,7 @@ curl -i http://<反代地址>/api/catalog/version | 现象 | 原因 | |--|--| -| 反代 502 / 504 | 后端未启动,或 `HOST` 绑定地址不对(同机应为 `127.0.0.1`,异机应为 `0.0.0.0`) | +| 反代 502 / 504 | 后端未启动,或 `HOST` 绑定地址不对(同机应为 `127.0.0.1`,异机应为 `0.0.0.0`) | | 页面能开、接口 404 | 只转发了 `/`,需要把 `/api/`、`/health`、`/static/` 一并转发(最简单是全匹配) | | 上传大文件 413 | 代理层上传上限没放开(见第三、四、五节) | | 上传中途断开 | 代理超时太短 | @@ -210,7 +210,7 @@ curl -i http://<反代地址>/api/catalog/version 在运维侧配好反代之前,可以直接访问后端业务服务(本项目开箱即用,不依赖反代): 1. `deploy/.env` 中 `HOST=0.0.0.0`(若只想让某个网段访问,也可填具体网卡地址) -2. 防火墙放行后端端口(默认 `3000/tcp`) +2. 防火墙放行后端端口(默认 `3000/tcp`) 3. 浏览器访问 `http://<服务器IP>:3000` > 注意:服务默认在 **3000** 端口,不在 80/443。直接访问 `http://<服务器IP>/` diff --git a/tasks/001-scaffold.md b/tasks/001-scaffold.md index 3634be4..4ec0a0f 100644 --- a/tasks/001-scaffold.md +++ b/tasks/001-scaffold.md @@ -1,6 +1,6 @@ # T-WEB-001 骨架 + 连接现有 MySQL -- **状态**:done +- **状态**: done - **依赖**:无 ## 目标 @@ -29,7 +29,7 @@ web-server/src/db/data-source.ts | `web-server/src/app.module.ts` | 根模块,`TypeOrmModule.forRootAsync` 由 `AppConfig` 注入连接信息 | | `web-server/src/config/configuration.ts` | 类型化配置层,唯一读取 `process.env` 的位置 | | `web-server/src/config/config.module.ts` | 全局配置模块 | -| `web-server/src/db/data-source.ts` | TypeORM DataSource(CLI + 运行期共用),`synchronize: false` | +| `web-server/src/db/data-source.ts` | TypeORM DataSource(CLI + 运行期共用), `synchronize: false` | | `web-server/src/health/health.controller.ts` | `GET /health`,真实探测数据库连通性 | | `web-server/.env.example` | 环境变量样例 | @@ -61,4 +61,4 @@ curl -s http://localhost:3000/health ## 结果 -- 已连通 `<数据库主机>:3306/opendance`,`/health` 返回 200 且 `connected=true`。 +- 已连通 `<数据库主机>:3306/opendance`, `/health` 返回 200 且 `connected=true`。 diff --git a/tasks/002-db-migration.md b/tasks/002-db-migration.md index 277cafc..294c371 100644 --- a/tasks/002-db-migration.md +++ b/tasks/002-db-migration.md @@ -1,7 +1,7 @@ # T-WEB-002 TypeORM 迁移生成表结构 -- **状态**:done -- **依赖**:T-WEB-001 +- **状态**: done +- **依赖**: T-WEB-001 ## 目标 @@ -25,7 +25,7 @@ web-server/src/db/data-source.ts |---|---|---| | `assets` | 上传文件元数据 | `sha256` 唯一索引(去重)、`stored_path`、`size` | | `songs` | 曲目 | 来源五元组、`status`、`deleted_at` | -| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 | +| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 | | `models` | 角色模型 | `format`、`vrm_version`、`is_primary` | | `review_records` | 审核流水 | `(target_type, target_id)` 复合索引 | @@ -33,7 +33,7 @@ web-server/src/db/data-source.ts - 表名 `snake_case` 复数;主键 `BIGINT AUTO_INCREMENT` - 时间列 `DATETIME`;软删除 `deleted_at` -- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异) +- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异) ## 交付物 diff --git a/tasks/003-song-crud.md b/tasks/003-song-crud.md index e0959f9..22cbf5f 100644 --- a/tasks/003-song-crud.md +++ b/tasks/003-song-crud.md @@ -1,7 +1,7 @@ # T-WEB-003 /api/songs CRUD -- **状态**:done -- **依赖**:T-WEB-002 +- **状态**: done +- **依赖**: T-WEB-002 ## 目标 @@ -27,7 +27,7 @@ web-server/src/common/pagination.ts | GET | `/api/songs/:id` | 详情 | | PATCH | `/api/songs/:id` | 更新(来源需五元组同时提交) | | DELETE | `/api/songs/:id` | **软删除** | -| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) | +| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) | | POST | `/api/songs/:id/cover` | 上传封面 | ## 关键实现 diff --git a/tasks/004-motion-upload.md b/tasks/004-motion-upload.md index d64b3c4..b82ea57 100644 --- a/tasks/004-motion-upload.md +++ b/tasks/004-motion-upload.md @@ -1,7 +1,7 @@ # T-WEB-004 动作数据上传 + 来源字段 -- **状态**:done -- **依赖**:T-WEB-002 +- **状态**: done +- **依赖**: T-WEB-002 ## 目标 @@ -29,17 +29,17 @@ 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`) | +| PATCH | `/api/motions/:id` | 更新元数据(含 `detachSong`) | | DELETE | `/api/motions/:id` | 软删除 | -## 存储策略(`contracts/model-formats.md` 3) +## 存储策略(`contracts/model-formats.md` 3) ``` ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext} ``` -- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存 -- `StorageService` 流式计算 sha256;**相同内容只落盘一次**,复用 `assets` 记录 +- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存 +- `StorageService` 流式计算 sha256; **相同内容只落盘一次**,复用 `assets` 记录 - 并发同文件上传靠 `assets.sha256` 唯一索引兜底,冲突后回退到已存在记录 - 路径永不使用原始文件名;`absolutePath()` 拒绝逃逸存储根目录 @@ -48,14 +48,14 @@ ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext} - 扩展名白名单:`.bvh.vmd.fbx.gltf.glb.json`,其他 -> 400 - 来源五元组缺任一项 -> 400 - `songId` 指向不存在的曲目 -> 400 -- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB) +- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB) -## VMD 支持(`.vmd`) +## VMD 支持(`.vmd`) -- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」) -- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp: +- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」) +- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp: `vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts`(标准骨架) - -> `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)-> `retarget.ts` 的 + -> `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)-> `retarget.ts` 的 `worldFramesToKeypointFrames()`(与 BVH 共用骨盆归零 / 自适应缩放 / 投影 / 关节角) - 固定 30 fps;腿部按 `足IK` 是否带位移自动选 IK 或 FK - 上传后处理失败与 BVH 一致:写入 `preprocessState=failed` 与可读错误 @@ -73,8 +73,8 @@ VMD 实测(用 `../示例文件/` 下的两个样本): | 样本 | 帧数 | 腿部驱动 | 结果 | |--|--|--|--| -| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s | -| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s | +| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s | +| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s | 上传前可先用诊断工具自查(只读): diff --git a/tasks/005-model-upload.md b/tasks/005-model-upload.md index e371181..5f8c274 100644 --- a/tasks/005-model-upload.md +++ b/tasks/005-model-upload.md @@ -1,12 +1,12 @@ # T-WEB-005 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + zip 自动搜索 + 分析/缩略图 + 上传即待审) -- **状态**:done(v0.2.0 重构:GLB/VRM 主力、上传即待审) -- **依赖**:T-WEB-002 +- **状态**: done(v0.2.0 重构:GLB/VRM 主力、上传即待审) +- **依赖**: T-WEB-002 ## 目标 系统以 **GLB / VRM 为主力格式**(服务端与客户端都一等公民):`.glb`/`.vrm`/`.gltf` -**直接导入、不重编码**,`.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip` +**直接导入、不重编码**, `.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip` **自动搜索包内可用模型**。入库后自动**分析**(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性) 与生成**缩略图**;状态**一律 `pending`**,经审核后才可能成为默认模型。 @@ -38,11 +38,11 @@ web-server/scripts/import-default-model.mjs | 方法 | 路径 | 说明 | |--|--|--| | GET | `/api/models` | 分页 + `q`/`format`/`status`/`primaryOnly` | -| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`;**入库一律 pending** | -| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) | +| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`; **入库一律 pending** | +| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) | | PATCH | `/api/models/:id` | 更新 | | DELETE | `/api/models/:id` | 软删除 | -| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) | +| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) | ## 格式策略(服务端与客户端一致) @@ -54,7 +54,7 @@ web-server/scripts/import-default-model.mjs | `.zip` | **自动搜索包内可用模型**(见下) | 客户端(Godot)本来就用内置 `GLTFDocument` 加载 glTF/GLB/VRM -(`opendance-windows/src/model/vrm_loader.gd`),**无需改动**。服务端 humanoid 候选表 +(`opendance-windows/src/model/vrm_loader.gd`), **无需改动**。服务端 humanoid 候选表 与客户端 `humanoid_map.gd` **逐字对齐**,由 `test/model-inspector.spec.ts` 断言两边一致。 ## 压缩包自动搜索与转换 @@ -74,14 +74,14 @@ web-server/scripts/import-default-model.mjs | ZIP 读取 | `common/zip-reader.ts` | 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错 | | 选主模型 | `model-convert.ts` | 格式优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内"`MODEL_CONVERT_PMX` 指名字(默认 `Hatsune Miku.pmx`,大小写不敏感)-> 目录最浅 -> 文件最大" | | 直接导入 | `importModelFile()` | `.vrm`/`.glb`/`.gltf` 原样入库;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM | -| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) | +| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) | | 回读校验 | `exportAndValidate()` | 每步产物**必须** `assimp info` 回读;退出码 0 但产物损坏也判失败 | | GLB 打包 | `gltf-packer.ts` | GLB 直出失败时导出 `.gltf`,把 `.bin` 与外部贴图自行合成单文件 GLB | -| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`;humanoid 骨骼不全 -> **回退交付 GLB**,缺失骨骼写进说明 | -| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) | +| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`; humanoid 骨骼不全 -> **回退交付 GLB**,缺失骨骼写进说明 | +| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) | | 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 -> `models.source_asset_id`;说明 -> `models.conversion_note` | | 失败 | `models.service.ts` | 无可用模型 / Assimp 不可用 / 两条路径都失败 -> **HTTP 422**,不产生模型记录 | -| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`) | +| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`) | | DTO | `conversionNote` / `fileSha256` / `sourceSha256` / `stats` / `thumbnailUrl` | 契约 `data-models.md` 4、`rest-api.yaml` | ## 入库后自动:分析 + 缩略图(尽力而为,失败不影响入库) @@ -93,11 +93,11 @@ web-server/scripts/import-default-model.mjs ## 状态与默认模型(硬性) -- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`), - `is_primary=false`:**不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。 -- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`: - 1. 审核通过带 `setPrimary:true`:`POST /api/reviews/model/:id/approve`; - 2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。 +- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`), + `is_primary=false`: **不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。 +- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`: + 1. 审核通过带 `setPrimary:true`: `POST /api/reviews/model/:id/approve`; + 2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。 - `GET /api/catalog/export` 的 `defaultModelId` 解析顺序(`resolveDefaultModelId()`): ``` @@ -116,27 +116,27 @@ web-server/scripts/import-default-model.mjs `vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库: -- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`) +- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`) - `extensions.VRM` -> VRM **0.x** -- 两者皆无 -> `null`(普通 GLB) +- 两者皆无 -> `null`(普通 GLB) -## 实测(v0.2.0) +## 实测(v0.2.0) - 上传含 `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`; + bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23`; `missingRequiredBones` 为**全部 16 根**(日文 MMD 骨名无法映射到 humanoid)。 -- 直接上传 `.glb` -> **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`, +- 直接上传 `.glb` -> **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`, 入库文件 sha256 与源文件**一致**(不重编码)。 - 缩略图:512x512 PNG / 15,043 字节,服务端纯 TS 光栅化(含蒙皮),目视确认为居中的 - T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。 -- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200, - 模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`; + T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。 +- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200, + 模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`; 重建后 `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22** (库中另有 2 个已发布 VRM)。 -### 运维 CLI:`web-server/scripts/import-default-model.mjs` +### 运维 CLI: `web-server/scripts/import-default-model.mjs` 服务进程在 systemd 加固(`ProtectSystem=strict`)下写不了程序目录,源码种子 通常由本脚本在沙箱外补写。需先 `npm run build`(复用 `dist/models/default-model-source.js`)。 @@ -146,15 +146,15 @@ 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-type other -# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts +# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts node scripts/import-default-model.mjs --write-source-only --model-id 22 # 只补写源码 node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run ``` 上传一律 pending,因此要让 CLI 写出的种子在 `defaultModelId` 中生效,仍需审核通过 -(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`; -`--url` 默认 `http://127.0.0.1:3000`,`--source-type` 默认 `other`。 +(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`; +`--url` 默认 `http://127.0.0.1:3000`, `--source-type` 默认 `other`。 ## 验收 @@ -170,7 +170,7 @@ npm run test:smoke # 冒烟 [4] 段落 | `.zip` 自动搜索包内模型 | ✅ 优先级 `.vrm`>`glb`>`.gltf`>`.pmx`>`.fbx`;同优先级按指名字->目录最浅->文件最大 | | `.pmx` 转换优先 GLB | ✅ 实测 201 / 0.86s、`format=glb`、2 张贴图内嵌 | | GLB 失败回退 glTF->GLB 打包 | ✅ GLB 直出失败时 `gltf-packer.ts` 自行打包;每步 `assimp info` 回读校验 | -| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`;humanoid 不全回退 GLB | +| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`; humanoid 不全回退 GLB | | 自动分析写入 `models.stats` | ✅ 实测 `triangles=9071 bones=238 humanoidMapped=0/22 drivable=false` | | 缩略图生成且不影响入库 | ✅ 512x512 / 15,043 B;失败只告警 | | 上传一律 pending、不自动设默认 | ✅ `create()` 固定 `PublishStatus.Pending` + `isPrimary=false` | @@ -179,15 +179,15 @@ npm run test:smoke # 冒烟 [4] 段落 | 失败语义 | ✅ 无可用模型 / Assimp 不可用 / 两条路径均失败 -> HTTP 422,不产生模型记录 | | ZIP 安全 | ✅ 拒绝 zip-slip、加密条目、未知压缩方式(`model-archive.spec.ts` 24 项) | | humanoid 与客户端一致 | ✅ `model-inspector.spec.ts` 抽 `humanoid_map.gd` 逐键比对(25 项) | -| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) | +| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) | -`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、 +`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、 主模型挑选优先级、GLB 贴图内嵌、用**假 Assimp** 对照多种回退路径、源码渲染 + `tsc` 校验。 -`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根 +`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根 humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。 -`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。 +`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。 -## 追加:历史模型回填(`POST /api/models/:id/reanalyze`) +## 追加:历史模型回填(`POST /api/models/:id/reanalyze`) 升级到带 `models.stats` 的版本后,已有模型还没有分析与缩略图。重新分析接口只改 `stats` 与 `thumbnailAssetId` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。 @@ -198,8 +198,8 @@ node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 回填 node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列清单 ``` -实测(真实 21MB VRM,290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 / +实测(真实 21MB VRM, 290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 / humanoid 7/22,并重建 512x512 缩略图。 文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。 -另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**, +另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**, 避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。 diff --git a/tasks/006-keypoint-preprocess.md b/tasks/006-keypoint-preprocess.md index 36e4e51..82c07b3 100644 --- a/tasks/006-keypoint-preprocess.md +++ b/tasks/006-keypoint-preprocess.md @@ -1,7 +1,7 @@ # T-WEB-006 BVH/VMD/FBX -> 关键点序列预处理 -- **状态**:done -- **依赖**:T-WEB-004 +- **状态**: done +- **依赖**: T-WEB-004 ## 目标 @@ -28,7 +28,7 @@ web-server/src/preprocess/* | 文件 | 职责 | |--|--| -| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) | +| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) | | `vmd-parser.ts` | VMD 二进制解析:30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼关键帧 + morph 段;MMD 贝塞尔插值,固定 30fps | | `mmd-skeleton.ts` | 标准 MMD 骨架(含全角/半角 `IK`/`IK` 别名归一);静止比例从参考 PMX 实测 | | `mmd-pose.ts` | MMD FK + 两骨骼解析腿部 IK + 左手系->右手系 Z 镜像,输出逐帧世界坐标 | @@ -36,14 +36,14 @@ web-server/src/preprocess/* | `joint-map.ts` | 关节名 -> MediaPipe 点索引的默认映射表 | | `retarget.ts` | 导出的 `worldFramesToKeypointFrames()`:任意骨架世界坐标 -> 骨盆归零 -> 自适应缩放 -> 投影 -> 9 个关节角;BVH 与 VMD 共用 | | `keypoint-codec.ts` | 239 字节二进制帧编解码、NDJSON 序列化/解析、关键点 JSON 校验 | -| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF -> BVH) | +| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF -> BVH) | | `preprocess.service.ts` | 编排、落盘、状态与错误记录 | ## 拓扑与缩放(契约冻结) - 33 点顺序与父节点表来自 `contracts/skeleton-topology.md`,虚拟关节 `neck=33`、`pelvis=34` 只参与关节角计算,**不进入输出序列**。 -- 投影:`x_img = 0.5 + x s`,`y_img = 0.5 - y s`,`z_img = -z s`, +- 投影:`x_img = 0.5 + x s`, `y_img = 0.5 - y s`, `z_img = -z s`, `s = 0.35 / max(|x|,|y|,|z|)`(按整段动作自适应,保证不出画)。 - 置信度分层:直接映射 **1.0**、按层级推算 **0.5**、无法推算 **0.0**。 @@ -53,7 +53,7 @@ web-server/src/preprocess/* |--|--| | `.bvh` | 内置解析器,无外部依赖 | | `.vmd` | 内置解析器,无外部依赖;固定 30fps,见「VMD 通路」 | -| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) | +| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) | | `.fbx` `.gltf` `.glb` | 调用可选 Assimp 转 BVH;未安装 Assimp 时写入可读错误并置 `preprocessState=failed` | ## VMD 通路 @@ -67,13 +67,13 @@ web-server/src/preprocess/* - VMD 不含骨骼长度、只有旋转(少数骨骼带位移),必须靠 `mmd-skeleton.ts` 的 标准骨架做 FK;骨骼名按别名表归一(全角 `IK` / 半角 `IK`),未纳入标准骨架的 骨骼(裙摆 / 物理 / 表情等)被忽略但计数。 -- **腿部驱动自动判定**:`足IK` 轨道存在非零位移 -> 两骨骼解析 IK(膝盖朝角色 +- **腿部驱动自动判定**: `足IK` 轨道存在非零位移 -> 两骨骼解析 IK(膝盖朝角色 正面弯,目标不可达时收敛为伸直,不产生 NaN);否则走 FK 旋转(部分动作数据把 腿烘焙成 FK)。 -- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z +- **手性**: MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反)后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中, `zMirrored` 下「膝盖朝前」占 94.1%,不转换仅 5.9%(见 `test/mmd-vmd.spec.ts`)。 -- 重定向**完全复用 BVH 通路**:`worldFramesToKeypointFrames()` 负责骨盆归零 / +- 重定向**完全复用 BVH 通路**: `worldFramesToKeypointFrames()` 负责骨盆归零 / 自适应缩放 / 图像空间投影 / 9 个关节角,因此两种来源的产物结构一致。 - 固定 **30 fps**,上传时填写的 `fps` 被忽略。 @@ -91,7 +91,7 @@ NDJSON 往返、解码器拒绝错误长度与 magic。 VMD 侧另测:二进制解析与截断报错、贝塞尔/四元数插值、标准骨架比例与镜像、 FK 与腿部 IK(可达/不可达/退化)、手性对照、合成 VMD 端到端 0 NaN / 0 越界。 -两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK) +两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK) 约 0.4s、`恋爱循环.vmd` 4604 帧(烘焙 FK)约 2.5s,均 0 NaN / 0 越界。 样例数据由 `test/fixtures/generate-sample-bvh.mjs` 生成(60 帧挥手 + 深蹲, diff --git a/tasks/007-upload-review.md b/tasks/007-upload-review.md index d3071c0..2b77d62 100644 --- a/tasks/007-upload-review.md +++ b/tasks/007-upload-review.md @@ -1,7 +1,7 @@ # T-WEB-007 上传审核流程 -- **状态**:done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`) -- **依赖**:T-WEB-003 / 004 / 005 +- **状态**: done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`) +- **依赖**: T-WEB-003 / 004 / 005 ## 目标 @@ -34,45 +34,45 @@ draft ──submit──▶ pending ──approve──▶ published | GET | `/api/reviews` | 不传 `status` -> 审核流水;传 `status` -> 该状态待审资产(三类合并) | | GET | `/api/reviews/:targetType/:id/history` | 单个资产的审核历史 | | POST | `/api/reviews/:targetType/:id/submit` | -> `pending` | -| POST | `/api/reviews/:targetType/:id/approve` | -> `published`;**模型**可带 `setPrimary:true` | -| POST | `/api/reviews/:targetType/:id/reject` | -> `rejected`,**必须填写意见** | +| POST | `/api/reviews/:targetType/:id/approve` | -> `published`; **模型**可带 `setPrimary:true` | +| POST | `/api/reviews/:targetType/:id/reject` | -> `rejected`, **必须填写意见** | | POST | `/api/reviews/:targetType/:id/withdraw` | -> `draft` | ## 关键实现 -- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**, +- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**, 重复审核同一状态同样 409。 - 驳回未填 `comment` -> **400**(合规要求:拒绝侵权内容需留痕)。 - 每次成功流转都写一条 `review_records`,记录 `fromStatus` / `toStatus` / `reviewer` / `comment`。 - 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。 - `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。 -- **模型上传只能 pending**:`ModelsService.create()` 固定 +- **模型上传只能 pending**: `ModelsService.create()` 固定 `status=PublishStatus.Pending` + `isPrimary=false`,请求体里的 `autoDefault` 等旁路一律不生效(硬性约束,见 DSH.md 6.9)。 -## 模型审核面板(v0.2.0) +## 模型审核面板(v0.2.0) 待审列表只有 `PendingItem`(不含模型详情),面板用 `item.targetId` 现拉 -`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`): +`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`): | 区域 | 内容 | |--|--| | 基本 | 缩略图、格式、状态、文件地址、转换说明 | -| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 | +| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 | | 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 -> 可驱动;不全 -> 列出缺哪些(直接标红);非 glTF 容器 -> 只能审核来源与体积 | -| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 | +| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 | | warnings | `ModelStats.warnings` 逐条列出 | | 骨骼详细界面 | 扁平骨骼树按 `depth` 缩进(14px/层),显示骨骼名、是否蒙皮徽章、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + **只渲染前 400 行**(超出提示还有多少根),千根骨骼不卡 | | stats 为空 | 提示"暂无分析数据(旧数据或后端尚未完成分析)",只能按格式/体积/来源判断 | ### 通过 / 驳回 / 设为默认 -- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`: +- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`: `ReviewsService.transition()` 保存模型为 `published` 后调用 `ModelsService.setPrimary(id)`(同一事务清其余 `is_primary`),并把身份写进源码 `src/config/default-model.ts`。 -- 单独设置走 `POST /api/models/:id/primary`:**仅 `published` 允许,否则 409**。 +- 单独设置走 `POST /api/models/:id/primary`: **仅 `published` 允许,否则 409**。 - 驳回必须填意见(400)。 ### 骨骼树过滤(`admin/src/lib/bone-tree.ts`,纯函数、可单测) @@ -101,4 +101,4 @@ npm run test:smoke | `setPrimary` 随审核通过生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` -> 200、`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...` | | 单独设默认仅 published | ✅ `POST /api/models/:id/primary` 非 published -> 409 | | 驳回必须填意见 | ✅ 无 `comment` -> 400 | -| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) | +| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) | diff --git a/tasks/008-admin-ui-songs.md b/tasks/008-admin-ui-songs.md index af61007..c91ce15 100644 --- a/tasks/008-admin-ui-songs.md +++ b/tasks/008-admin-ui-songs.md @@ -1,7 +1,7 @@ # T-WEB-008 歌曲管理界面 -- **状态**:done -- **依赖**:T-WEB-003 +- **状态**: done +- **依赖**: T-WEB-003 ## 目标 @@ -19,11 +19,11 @@ web-server/admin/package.json ## 交付物 -`web-server/admin/`(React 18 + Vite + TypeScript): +`web-server/admin/`(React 18 + Vite + TypeScript): | 路径 | 说明 | |--|--| -| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) | +| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) | | `src/components/SongForm*` | 新建/编辑表单,含来源五元组 | | `src/api/client.ts` | fetch 封装;非 2xx 抛 `ApiError`,兼容 Nest 的 `message: string[]` | | `src/api/types.ts` | 与契约对齐的 TypeScript 类型 | @@ -48,6 +48,6 @@ npm run build # tsc -b && vite build 必须成功 ## 构建与运行 -- 开发:`npm run dev`(5173,`/api` 与 `/health` 代理到 `localhost:3000`) -- 生产:`npm run build` 产物由**后端服务自身**托管(`ADMIN_DIR`,含 SPA 回退),反向代理只需整体转发到后端端口(见 `deploy/proxy/README.md`) +- 开发:`npm run dev`(5173, `/api` 与 `/health` 代理到 `localhost:3000`) +- 生产:`npm run build` 产物由**后端服务自身**托管(`ADMIN_DIR`,含 SPA 回退),反向代理只需整体转发到后端端口(见 `deploy/proxy/README.md`) - `VITE_API_BASE` 可覆盖 API 基地址;留空表示同源 diff --git a/tasks/009-admin-ui-models.md b/tasks/009-admin-ui-models.md index bedb387..a281d77 100644 --- a/tasks/009-admin-ui-models.md +++ b/tasks/009-admin-ui-models.md @@ -1,7 +1,7 @@ # T-WEB-009 模型管理界面 + 来源展示 -- **状态**:done -- **依赖**:T-WEB-005 +- **状态**: done +- **依赖**: T-WEB-005 ## 目标 @@ -30,11 +30,11 @@ web-server/admin/src/components/VrmPreview.tsx - 表格列:预览缩略图、名称、格式(VRM 高亮徽章)、VRM 版本、是否默认、 **来源五元组**、许可、状态、操作 - **来源展示(本任务验收重点)**:整行展示 `sourceType` + `sourceName` + `author` - + `license`,`sourceUrl` 可点击外链跳转 -- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 + + + `license`, `sourceUrl` 可点击外链跳转 +- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 + 状态),客户端校验与歌曲页一致 - 「设为默认」-> `POST /api/models/:id/primary`,成功后刷新列表以反映互斥结果 -- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`, +- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`, 支持鼠标拖拽旋转与滚轮缩放,正确处理 VRM 0.x / 1.0 元数据差异、 未选择模型、加载失败三种状态,卸载时释放资源 @@ -45,5 +45,5 @@ cd web-server/admin npm run build # 必须成功 ``` -另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段, +另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段, 供本页面展示。 diff --git a/tasks/010-deploy.md b/tasks/010-deploy.md index 7fd51a9..28ab7d5 100644 --- a/tasks/010-deploy.md +++ b/tasks/010-deploy.md @@ -1,17 +1,17 @@ # T-WEB-010 部署(Linux 业务服务,不使用 Docker,不部署反代) -- **状态**:done(两次需求变更后重做) -- **依赖**:T-WEB-001 ~ 009 +- **状态**: done(两次需求变更后重做) +- **依赖**: T-WEB-001 ~ 009 ## 目标 Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程由系统服务 -(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。 -**不使用 Docker**,**不部署 MySQL**(数据库为部署方提供的现有实例), +(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。 +**不使用 Docker**, **不部署 MySQL**(数据库为部署方提供的现有实例), **也不部署反向代理**(由另外的服务/团队负责)。 > **需求变更记录** -> 1. 初版按文档 4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站", +> 1. 初版按文档 4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站", > 于是替换为 nginx + systemd 原生站点部署,并删除 `deploy/docker-compose.yml`、 > `web-server/Dockerfile`、`web-server/admin/Dockerfile`。 > 2. 随后用户明确:"web 端仅需要业务需求,反代等工作交由另外的服务处理。 @@ -20,7 +20,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程 > 托管(`ADMIN_DIR` + SPA 深链接回退),并新增 `deploy/proxy/` 只交付说明文档。 > 3. 再随后用户要求**管理端口与软件 API 端口分离**:新增 `ADMIN_PORT` > (默认 `PORT + 1`)与可选的 `ADMIN_HOST`(默认跟随 `HOST`)。软件 API 端口 -> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404), +> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`, `/` 等返回 404), > 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为 > 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。 > 4. 本次追加两项: @@ -29,7 +29,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程 > `install.sh` 在缺 `deploy/.env` 时先调用它,失败才 die(安装目录已有上次 > 安装留下的.env 则直接复用,保持原语义)。 > b) **默认监听 0.0.0.0 固化**:确认 `configuration.ts` 的 -> `bindHost = process.env.HOST?? '0.0.0.0'`、`adminHost` 默认跟随 HOST, +> `bindHost = process.env.HOST?? '0.0.0.0'`、`adminHost` 默认跟随 HOST, > 两个 `.env.example` 与 `init-env.sh` 的 HOST 默认值均为 `0.0.0.0`。 ## load @@ -52,10 +52,10 @@ deploy/proxy/README.md ``` Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供接口,无管理页面) ├── /api/ REST API - ├── /health 健康检查(数据库不可用返回 503) + ├── /health 健康检查(数据库不可用返回 503) ├── /static/ 上传文件 └── /contracts/ 契约快照 - (/、/songs 等返回 404,有意设计) + (/、/songs 等返回 404,有意设计) 浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关...)──▶ 管理端端口:3001 │ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退) @@ -81,15 +81,15 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供 | `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) | | `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 | | `deploy/openrc/opendance-api` | OpenRC 服务脚本 | -| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) | +| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) | | `deploy/ctl.sh` | 无 init 系统时的进程控制(后台进程 + PID 文件),start/stop/restart/status/logs | | `deploy/proxy/README.md` | **反代说明文档**:转发目标、绑定地址、自检清单、故障对照 | -| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) | +| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) | | `deploy/proxy/web.config.example` | IIS 配置样例(需 ARR + URL Rewrite 模块) | -## deploy/.env 初始化引导(init-env.sh) +## deploy/.env 初始化引导(init-env.sh) -首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`: +首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`: ``` install.sh 检测到缺 deploy/.env @@ -97,11 +97,11 @@ install.sh 检测到缺 deploy/.env └── 否则调用 deploy/init-env.sh ├── 缺.env -> 从.env.example 复制,chmod 600 ├── 交互(TTY / 管道)-> 逐项询问并写回 - └── 非交互(CI、 只校验,绝不阻塞;失败则 install.sh die + └── 非交互(CI、 只校验,绝不阻塞;失败则 install.sh die ``` 逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST` -共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**; +共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**; 校验必填项与端口范围;可用 Node + mysql2 做连通性自检(口令只经环境变量传给子进程, 失败仅告警);只替换/追加这 8 个键、保留模板注释,写完 `chmod 600` 并回读校验; 结尾打印摘要(口令 `***`)与本次变更列表。 @@ -116,21 +116,21 @@ install.sh 检测到缺 deploy/.env | `--skip-db-check` | 跳过数据库连通性自检 | | `--dir DIR` | 等价于 `--env-file DIR/.env --example DIR/.env.example` | | `--env-file PATH` / `--example PATH` | 指定 `.env` / 模板路径 | -| `--timeout SECONDS` | 非 TTY 下每次读取超时秒数(默认 10) | +| `--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 且是 ` +**非交互绝不挂住**: stdin 非 TTY 且是 ` 只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。 -**默认监听 0.0.0.0**:`configuration.ts` 的 -`bindHost = process.env.HOST?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST` -(HOST 未设置时同样回落 `0.0.0.0`);`deploy/.env.example` 与 -`web-server/.env.example` 均写 `HOST=0.0.0.0`;`install.sh` 的 -`BIND_HOST="${HOST:-0.0.0.0}"`;`init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。 +**默认监听 0.0.0.0**: `configuration.ts` 的 +`bindHost = process.env.HOST?? '0.0.0.0'`, `adminHost` 默认跟随 `HOST` +(HOST 未设置时同样回落 `0.0.0.0`); `deploy/.env.example` 与 +`web-server/.env.example` 均写 `HOST=0.0.0.0`; `install.sh` 的 +`BIND_HOST="${HOST:-0.0.0.0}"`; `init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。 注意 `install.sh` 里的 `HEALTH_HOST` / `API_HOST` 用的是**回环地址**做本机健康检查, 与对外监听的默认值无关。 @@ -142,7 +142,7 @@ install.sh 检测到缺 deploy/.env 1. systemd /run/systemd/system 存在且 systemctl 可用 2. OpenRC rc-service + rc-update 可用 3. SysVinit /etc/init.d 存在且 update-rc.d 或 chkconfig 可用 -4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh) +4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh) 并写 crontab @reboot 实现开机自启 ``` @@ -156,45 +156,45 @@ install.sh 检测到缺 deploy/.env ## 关键实现 -- **双端口**:一个 Node 进程创建两个 HTTP server--软件 API 端口(`PORT`,默认 3000) +- **双端口**:一个 Node 进程创建两个 HTTP server--软件 API 端口(`PORT`,默认 3000) 与管理端端口(`ADMIN_PORT`,默认 3001),复用同一个 express 实例;用 `req.socket.localPort` 判断请求来自哪个端口,决定是否提供管理页面。 - `ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE) -- **服务自身托管管理端**:`app.useStaticAssets(adminDir,...)` + SPA 回退中间件 + `ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE) +- **服务自身托管管理端**: `app.useStaticAssets(adminDir,...)` + SPA 回退中间件 (仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/` 以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML -- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计) +- **软件 API 端口不提供管理页面**: `/`、`/songs` 等在该端口一律 404(有意设计) - **`HOST` / `ADMIN_HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用 `0.0.0.0` + 防火墙;`ADMIN_HOST` 可选(默认跟随 `HOST`),可单独把管理端限制在 本机或内网;`HOST` 默认取 `0.0.0.0` 以保证开箱可用 -- **首次部署的.env 由引导生成**:`install.sh` 缺 `deploy/.env` 时先调用 +- **首次部署的.env 由引导生成**: `install.sh` 缺 `deploy/.env` 时先调用 `deploy/init-env.sh`(安装目录已有上次安装的 `.env` 则直接复用);引导脚本在 非交互环境下只校验、绝不阻塞,校验失败时 install.sh 才 die -- **端口校验**:`PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境 +- **端口校验**: `PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境 启动时快速失败(`validateEnv` 的 errors),`ADMIN_PORT === PORT` 只 warn - **install.sh 汇总与反代说明**:结尾分别打印两个端口的访问地址、监听配置与 防火墙放行命令(`firewall-cmd` / `ufw` 同时放行两个端口) -- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、 +- **systemd 加固**: `NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、 `ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录 -- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB), +- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB), 反代侧 `client_max_body_size` / `maxAllowedContentLength` 必须不小于此值 -- **模型转换配置**(`deploy/.env.example` 已写入): +- **模型转换配置**(`deploy/.env.example` 已写入): - `ASSIMP_BIN=assimp`:上传 `.zip` 模型包或 `.pmx`/`.fbx` 单文件时**必须**能调用到, 否则转换失败 **422** - `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`:只影响需要转换的 PMX/FBX 的交付格式。 `glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展 - (humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB** + (humanoid 骨骼 / 表情语义), **humanoid 骨骼名映射不全时自动回退交付 GLB** 并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验 - `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README 四 -- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走 - `typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies; +- **迁移用编译产物**: `web-server/scripts/migrate.mjs` 检测到 `dist/` 时走 + `typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies; 本版本迁移共 3 个:`InitSchema1789382903060`、 `AddModelConversionFields1789450000000`、**`AddModelStats1789460000000`** (新增 `models.stats TEXT`,存模型分析结果,已在真实库执行) - **依赖安装显式带 dev**:安装脚本先 `set -a;. deploy/.env` 导出了 - `NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`; + `NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`; 故用 `env -u NODE_ENV npm ci --include=dev` -- **复制文件时锚定排除**:`storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/` +- **复制文件时锚定排除**: `storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/` (曾导致 `MODULE_NOT_FOUND...storage.module`),排除规则一律用 `/storage/` 锚定 - **`/contracts/` 静态暴露**:另两端可直接从站点取契约快照 @@ -202,7 +202,7 @@ install.sh 检测到缺 deploy/.env ```bash bash deploy/init-env.sh # 或手工 cp deploy/.env.example deploy/.env 后填写 DB_* -bash deploy/install.sh # 需 root(不需要 sudo) +bash deploy/install.sh # 需 root(不需要 sudo) curl -fsS http://localhost:3000/health curl -fsS http://localhost:3001/health curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 期望 404(软件 API 端口无管理页面) @@ -212,12 +212,12 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 | 验收项 | 状态 | |--|--| | `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` | -| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) | -| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 | +| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200, `database.connected=true`) | +| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 | | 模型转换配置 | ✅ `deploy/.env.example` 写入 `ASSIMP_BIN` / `MODEL_CONVERT_FORMAT=glb|vrm` / `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS`;不合规输入转换失败 422 | | `curl http://:3000/health` 通过 | ✅ 直连后端端口已验证 | | 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) | -| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) | +| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) | | 前端深链接回退 | ✅ 未知静态路径回退到 `index.html`,未知 API 仍 404 | | `ADMIN_PORT === PORT` 单端口兼容模式 | ✅ 只建一个 server,管理页面回到 API 端口,日志提示「未分端口」 | | 端口非法在生产环境中止启动 | ✅ `validateEnv` 对非 1..65535 整数端口报 error | @@ -225,11 +225,11 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 | 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 | | 不部署反向代理 | ✅ 仅交付 `deploy/proxy/` 文档,install.sh 中无任何反代安装动作 | | 缺 `deploy/.env` 时由 `init-env.sh` 引导 | ✅ 交互逐项询问 8 个键;只改这 8 个键、保留模板注释、`chmod 600`、回读校验;安装目录已有 `.env` 时直接复用 | -| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 | +| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 | | `HOST` 默认 `0.0.0.0` | ✅ `configuration.ts`、`deploy/.env.example`、`web-server/.env.example`、`install.sh`、`init-env.sh` 五处一致 | > **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。 -> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab) +> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab) > 未能在本机实机跑通。已完成的等价验证: > - 后端以 `node dist/main.js` 直连真实数据库启动,两个端口 `/health` 均 200 > - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径), @@ -238,7 +238,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 > - `npm run migration:run` 走编译产物成功 > - 端到端冒烟 57 项全通过 > - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验 -> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖 +> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖 > 交互写回 / `--check` / 非交互 / 超时 / 幂等 / 口令不加引号 > - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查) > @@ -253,13 +253,13 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/` (含 `proxy/` 反代文档)、`contracts/` 快照、`THIRD_PARTY.md`、`README.txt`。 -打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`, +打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`, 关键文件(`dist/main.js`、`dist/storage/storage.module.js`、`admin/index.html`、 `deploy/proxy/*`)必须齐全,且不得含真实 `.env` 与 Docker/nginx 安装配置。 v0.2.0 起校验清单追加模型新模块:`dist/models/model-inspector.js`、 `dist/models/model-thumbnail.js`、`dist/models/vrm-writer.js`、 `dist/models/gltf-packer.js`、`dist/migrations/1789460000000-AddModelStats.js`。 -> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` -> **`opendance-web-v0.2.0.tar.gz`** -> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`, +> **发布包名变更**: `opendance-web-v0.1.0.tar.gz` -> **`opendance-web-v0.2.0.tar.gz`** +> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`, > 与 `project.yaml` 的 `version: 0.2.0`、`web-server/package.json` 一致)。 diff --git a/web-server/admin/README.md b/web-server/admin/README.md index ceb8dfc..9c20648 100644 --- a/web-server/admin/README.md +++ b/web-server/admin/README.md @@ -1,7 +1,7 @@ -# OpenDance Studio 管理端(opendance-admin) +# OpenDance Studio 管理端(opendance-admin) 基于 **React 18 + TypeScript + Vite** 的管理后台,对接 `web-server` 的 NestJS API。 -接口契约以 [`../contracts/rest-api.yaml`](../contracts/rest-api.yaml)(rest-api 0.1.0)为准, +接口契约以 [`../contracts/rest-api.yaml`](../contracts/rest-api.yaml)(rest-api 0.1.0)为准, 字段语义见 [`../contracts/data-models.md`](../contracts/data-models.md)。 界面语言为简体中文;无任何 UI 组件框架,全部样式集中在 `src/styles.css`。 @@ -11,16 +11,16 @@ ```bash npm install # 或 pnpm install npm run dev # 开发服务器 http://localhost:5173 -npm run build # 生产构建(tsc -b && vite build)-> dist/ +npm run build # 生产构建(tsc -b && vite build)-> dist/ npm run preview # 本地预览 dist/ -npm run typecheck # 只做类型检查(tsc --noEmit) +npm run typecheck # 只做类型检查(tsc --noEmit) ``` > 管理端请求的是**同源**的 `/api/**` 与 `/health`,不含任何硬编码域名。 -## 开发代理(dev proxy) +## 开发代理(dev proxy) -`vite.config.ts` 已把 `/api` 与 `/health` 代理到 `http://localhost:3000`: +`vite.config.ts` 已把 `/api` 与 `/health` 代理到 `http://localhost:3000`: ```ts server: { @@ -73,24 +73,24 @@ bash deploy/install.sh ``` - 后端通过 `ADMIN_DIR` 定位管理端产物,默认 `../admin` -- 反代配置样例:`deploy/proxy/nginx.conf.example`(nginx)、 - `deploy/proxy/web.config.example`(IIS) +- 反代配置样例:`deploy/proxy/nginx.conf.example`(nginx)、 + `deploy/proxy/web.config.example`(IIS) - 完整说明:`deploy/proxy/README.md` ## 目录结构 ``` src/ - main.tsx 入口(BrowserRouter) + main.tsx 入口(BrowserRouter) App.tsx 路由表 styles.css 全部样式 api/ - client.ts fetch 封装(ApiError / 分页 / multipart) + client.ts fetch 封装(ApiError / 分页 / multipart) types.ts 契约类型(与 rest-api.yaml 一一对应) endpoints.ts 契约端点封装 components/ Layout.tsx 左侧导航骨架 - ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单...) + ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单...) VrmPreview.tsx three + @pixiv/three-vrm 的 3D 预览(懒加载分包) lib/ labels.ts 枚举中文文案 @@ -119,12 +119,12 @@ src/ ## 3D 预览说明 -`src/components/VrmPreview.tsx`:`GLTFLoader` + `VRMLoaderPlugin`(来自 `@pixiv/three-vrm`), +`src/components/VrmPreview.tsx`: `GLTFLoader` + `VRMLoaderPlugin`(来自 `@pixiv/three-vrm`), -- 依据 `vrm.meta.metaVersion` 区分 VRM 0.x / 1.0,0.x 自动执行 `VRMUtils.rotateVRM0`; +- 依据 `vrm.meta.metaVersion` 区分 VRM 0.x / 1.0, 0.x 自动执行 `VRMUtils.rotateVRM0`; - 自实现鼠标拖拽旋转与滚轮缩放(未引入 OrbitControls); - `requestAnimationFrame` 循环内调用 `vrm.update(delta)`,并按包围盒自动取景; -- 卸载时取消 RAF、断开 ResizeObserver、释放 renderer / geometry / material / texture; +- 卸载时取消 RAF、断开 ResizeObserver、释放 renderer / geometry / material / texture; - 无 WebGL 上下文时降级为错误提示,不会让页面崩溃。 `three` + `@pixiv/three-vrm`(约 750 kB)通过 `React.lazy` 单独分包,仅进入模型页时加载。