diff --git a/contracts/VERSION b/contracts/VERSION index 29c3f7e..b559400 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -1,8 +1,8 @@ # OpenDance Studio 契约版本 # 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。 -rest-api: 0.3.0 +rest-api: 0.4.0 websocket-keypoints: 0.1.0 -data-models: 0.3.0 +data-models: 0.4.0 skeleton-topology: 0.1.0 -model-formats: 0.3.0 -database: 0.2.0 +model-formats: 0.4.0 +database: 0.3.0 diff --git a/contracts/data-models.md b/contracts/data-models.md index ea0167d..d4d7c13 100644 --- a/contracts/data-models.md +++ b/contracts/data-models.md @@ -1,4 +1,4 @@ -# contracts/data-models.md — 数据模型契约 v0.3.0 +# contracts/data-models.md — 数据模型契约 v0.4.0 Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。 字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。 @@ -96,7 +96,7 @@ rejected 已驳回 |---|---|---|---| | `id` | number | 读 | | | `name` | string | ✅ | 模型名称,≤200 | -| `format` | enum | ✅ | `vrm` / `glb` / `gltf` / `fbx` | +| `format` | enum | ✅ | `glb` / `vrm`(主力)/ `gltf` / `fbx`(历史数据)。上传的 PMX/FBX 会被服务端转换成 glb 或 vrm 再入库,见 model-formats.md §1.1 | | `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null | | `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** | | `fileUrl` | string | 读 | | @@ -105,6 +105,7 @@ rejected 已驳回 | `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`);直传模型为 null | | `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 —— **每次转换都会变**,仅作记录 | | `sourceSha256` | string\|null | 读 | 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null | +| `stats` | object\|null | 读 | 模型分析结果:面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,见 model-formats.md §1.1 | | `source*` | | ✅ | 来源五元组 | | `status` | `PublishStatus` | | | | `createdAt` / `updatedAt` | string | 读 | | diff --git a/contracts/database.md b/contracts/database.md index 44c0ecc..7860b4e 100644 --- a/contracts/database.md +++ b/contracts/database.md @@ -36,11 +36,12 @@ DB_LOGGING=false - 每个 migration 必须可逆(提供 `down` 方法) - 迁移文件名格式:`-.ts` -## 增量迁移(v0.2.0) +## 增量迁移(v0.2.0 / v0.3.0) | 迁移 | 变更 | |---|---| | `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) diff --git a/contracts/model-formats.md b/contracts/model-formats.md index 36af55c..ad0a74b 100644 --- a/contracts/model-formats.md +++ b/contracts/model-formats.md @@ -1,32 +1,69 @@ -# contracts/model-formats.md — 模型与动作格式契约 v0.3.0 +# contracts/model-formats.md — 模型与动作格式契约 v0.4.0 ## 1. 角色模型(models) -| 格式 | 扩展名 | 支持级别 | 说明 | +**系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。 + +| 格式 | 扩展名 | 支持级别 | 服务端行为 | |---|---|---|---| -| VRM | `.vrm` | **首选(P0)** | glTF 2.0 二进制 + VRM 扩展;Windows 端 godot-vrm 直接加载 | -| glTF Binary | `.glb` | P1 | 需含骨骼(skin);无骨骼则仅作静态展示 | -| glTF | `.gltf` (+ `.bin`, 贴图) | P2 | 多文件,管理端需打包为 zip 上传 | -| FBX | `.fbx` | P2 | 需 Assimp 转换,管理端仅归档,不保证重定向质量 | -| **MMD 压缩包** | `.zip` | P1 | 内含 `.pmx`(+ `textures/`)。服务端自动解压并转换,见 §1.1 | +| 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) | -### 1.1 模型压缩包转换(`.zip` → FBX / GLB) +### 1.1 上传与转换策略 -上传 `.zip` 时走"解压 → 选主模型 → 转换 → 入库",而不是把压缩包当模型文件存下来。 +**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**: +**优先 GLB,其次 VRM**。 + +#### 单文件上传 + +| 输入 | 行为 | +|---|---| +| `.glb` / `.vrm` | 直接导入;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM | +| `.gltf` | 直接导入(外部 `.bin` / 贴图不在同一文件里,会缺资源) | +| `.pmx` / `.fbx` | 转换:`ASSIMP_BIN` 导出 → 回读校验(见下) | + +#### 压缩包上传(自动搜索) + +不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**: + +``` +.vrm → .glb → .gltf (直接导入,优先) +.pmx → .fbx (需要转换) +``` + +同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。 +压缩包原件以 `model-archive` 资产留存并写入 `models.source_asset_id`。 + +#### 转换与校验(PMX / FBX) | 步骤 | 规则 | |---|---| -| 选主模型 | 优先 `MODEL_CONVERT_PMX` 指定的文件名(默认 `Hatsune Miku.pmx`,大小写不敏感);否则取目录最浅、其次文件最大者。**只认 `.pmx`**(MMD 的 PMX 模型;PMD/PMX 之外的格式暂不支持) | -| 转换 | `MODEL_CONVERT_FORMAT`(默认 `fbx`)指定首选格式,调 `ASSIMP_BIN` 导出 | -| 校验 | **导出后必须回读校验**(`assimp info`)。Assimp 的 FBX 导出器对带蒙皮的大骨架模型会"写出损坏文件"(实测 714 骨骼的 PMX:导出退出码 11,产物回读报 `FBX-DOM no Objects dictionary found`),只信退出码会把坏文件入库 | -| 回退 | FBX 失败(导出失败或回读失败)→ 自动改用 **GLB**,并把两次尝试写进 `models.conversion_note` | -| 贴图 | GLB 产物里 Assimp 会把贴图写成外部 URI(还带 `\` 反斜杠),服务端把压缩包内的贴图**内嵌**进 GLB 的二进制块,保证单文件可加载 | -| 溯源 | 压缩包原件以 `model-archive` 资产留存,写入 `models.source_asset_id` | -| 默认模型 | 转换成功且 `autoDefault` 不为 false:`is_primary=1` + `status=published`,并把身份写进源码 `web-server/src/config/default-model.ts`,见 `data-models.md` §4.1 | -| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换三次得到三个不同的 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**压缩包 sha256** 为匹配键,产物 sha256 只作记录 | -| 失败 | 压缩包里没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,不产生模型记录 | +| 1. GLB 直出 | `assimp export → .glb`,成功后把 Assimp 写成外部 URI 的贴图**内嵌**进 BIN chunk | +| 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 张贴图`) | +| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 → **HTTP 422**,不产生模型记录 | -安全:解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE。 +#### 入库后(自动) + +| 项 | 说明 | +|---|---| +| 状态 | **一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费,见 `database.md` 状态机 | +| 分析 | `model-inspector.ts` 计算面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,写入 `models.stats`,供审核界面展示 | +| 缩略图 | `model-thumbnail.ts` 纯 TS 光栅化生成 PNG,写入 `models.thumbnail_asset_id`(失败不影响入库) | +| 默认模型 | 只在**审核通过并勾选"同时设为默认"**(或 `POST /api/models/:id/primary`)时设置,并把身份写进源码 `src/config/default-model.ts`,见 `data-models.md` §4.1 | + +#### 其它约定 + +| 项 | 规则 | +|---|---| +| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 | +| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE | **VRM 优先**的选取顺序见 `data-models.md` §4.1。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 0bbe2bb..2971303 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: OpenDance Studio Web 管理服务端 API - version: 0.3.0 + version: 0.4.0 description: | OpenDance Studio 的唯一数据源与契约权威。 契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。 @@ -317,24 +317,26 @@ paths: schema: { $ref: '#/components/schemas/ModelPage' } post: tags: [models] - summary: 上传模型 + 来源信息(VRM 优先;支持 MMD 模型压缩包自动转换) + summary: 上传模型(GLB/VRM 直接导入;PMX/FBX 服务端转 GLB 或 VRM) description: | - `file` 为单个文件: + 系统以 **GLB / VRM 为主力格式**(两个客户端都原生加载): - - `.vrm` / `.glb` / `.gltf` / `.fbx`:直接入库,`format` 由扩展名决定 - - `.zip`:视为 MMD 模型包。服务端解压 → 选取 `.pmx` - (优先 `MODEL_CONVERT_PMX` 指定的名字,默认 `Hatsune Miku.pmx`; - 否则取目录最浅、文件最大者)→ 调 Assimp 导出 **FBX**, - 导出后**回读校验**(Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写出损坏文件), - 失败自动回退 **GLB**,并把过程写入 `conversionNote`。 - GLB 产物会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,保证单文件可用。 - 压缩包原件作为 `model-archive` 资产留存以便溯源。 + - `.glb` / `.vrm` / `.gltf`:**直接导入**,不重编码 + - `.pmx` / `.fbx`:服务端转换,**优先 GLB、其次 VRM** + - `.zip`:**自动搜索包内可用模型** —— `.vrm` → `.glb` → `.gltf`(直接导入) + 优先,其次 `.pmx` → `.fbx`(转换)。压缩包原件作为 `model-archive` 资产留存。 - 转换成功且 `autoDefault` 不为 false 时,该模型会被置为全局默认模型 - (`isPrimary=1` + `status=published`),并把身份(名称/格式/**压缩包 sha256**) - 写进源码 `web-server/src/config/default-model.ts`。 - 注意:**转换产物不是字节级确定的**(同一份 zip 每次转换的产物 sha256 都不同), - 所以默认模型种子以压缩包 sha256 为匹配键,产物 sha256 只作记录。 + 转换链路(`model-convert.ts`):`assimp export → .glb` →(失败)`→ .gltf` 后 + 由服务端自行打包成 GLB →(`MODEL_CONVERT_FORMAT=vrm` 时)在 GLB 之上补 + `VRMC_vrm` 扩展产出 VRM 1.0。每一步都 `assimp info` **回读校验**, + 并把过程写进 `conversionNote`;贴图会内嵌进 GLB,保证单文件可用。 + + 入库时自动做两件事并写入 `stats` / `thumbnailUrl`:模型分析(面数、骨骼树、 + humanoid 覆盖、可驱动性)与缩略图生成。 + + **状态一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费; + 设为默认模型请在审核通过时带 `setPrimary=true`,或调用 + `POST /api/models/{id}/primary`(仅已发布模型可设)。 requestBody: required: true content: @@ -346,9 +348,7 @@ paths: file: { type: string, format: binary } name: { type: string } isPrimary: { type: boolean } - autoDefault: - type: boolean - description: 仅对 `.zip` 生效;转换成功后是否自动设为默认模型,默认 true + # 注意:上传不再提供"自动发布/自动设为默认",模型一律进入待审核 sourceType: { $ref: '#/components/schemas/SourceType' } sourceName: { type: string } sourceUrl: { type: string } @@ -777,6 +777,12 @@ components: type: string nullable: true description: 模型文件(转换产物)的 sha256 —— 每次转换都会变(Assimp 导出非字节级确定),仅作记录 + stats: + type: object + nullable: true + description: >- + 模型分析结果(面数 / 顶点数 / 贴图 / 骨骼树 / humanoid 覆盖 / 可驱动性), + 审核界面据此判断"能不能驱动与评分"。结构见 contracts/model-formats.md §1.1 sourceSha256: type: string nullable: true