feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0

需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
This commit is contained in:
2026-09-15 13:55:54 +08:00
parent bba5bcf59e
commit e4351c5062
34 changed files with 3204 additions and 65 deletions
+2
View File
@@ -67,6 +67,8 @@ export enum AssetKind {
SongCover = 'song-cover',
Motion = 'motion',
Model = 'model',
/** 上传的模型压缩包(zip)原件,仅用于溯源;入库的是转换后的模型文件 */
ModelArchive = 'model-archive',
Thumbnail = 'thumbnail',
Keypoints = 'keypoints',
}
+16 -1
View File
@@ -9,10 +9,25 @@ export interface UploadedFileLike {
size: number;
}
/**
* 修正 multipart 文件名编码。
*
* busboy/multer 按 latin1 解出 `originalname`,含中文/日文/emoji 的文件名会变成
* 乱码(例如 `模型_初音…zip` → `模å_åé³…`)。这里用 latin1 还原原始字节再按
* UTF-8 解一次;只有当结果不含替换字符、且原文确实含高位字节时才替换,
* 避免破坏真正的 latin1 文件名。
*/
export function decodeUploadName(rawName: string): string {
if (![...rawName].some((ch) => ch.charCodeAt(0) > 0x7f)) return rawName;
const restored = Buffer.from(rawName, 'latin1').toString('utf8');
if (restored.includes('\uFFFD')) return rawName;
return restored;
}
export function requireUploadedFile(file: UploadedFileLike | undefined, label = 'file'): UploadedFileLike {
if (!file) throw new BadRequestException(`缺少上传文件字段 ${label}`);
if (!file.path) throw new BadRequestException('上传文件未落盘');
return file;
return { ...file, originalname: decodeUploadName(file.originalname) };
}
/** 按扩展名白名单解析格式;不在白名单内一律 422 */
+215
View File
@@ -0,0 +1,215 @@
import { TextDecoder } from 'node:util';
import { inflateRawSync } from 'node:zlib';
/**
* 极简 ZIP 读取器(只读,纯 Node 内置能力)。
*
* 为什么不用现成库:本项目的约定是"能用内置能力就不加依赖"(BVH、VMD 解析器同理),
* 而 MMD 模型压缩包只需要 STORE / DEFLATE 两种最普通的条目,实现成本很低。
*
* 支持:
* - 中央目录解析(EOCD → 条目表),STORE(0) 与 DEFLATE(8)
* - ZIP64 的"条目数/大小溢出"字段(部分压缩工具对大包会写)
* - 文件名编码:UTF-8 标志位;否则优先 Shift-JIS(Windows 压缩工具的中文/日文常见),
* 失败再退 CP437 近似(latin1)
*
* 明确不支持(遇到时给出清晰错误,而不是产出错数据):加密条目、分卷压缩、
* ZIP64 的 64 位偏移(真需要时会报错)。
*/
export interface ZipEntry {
/** 压缩包内的相对路径(已把 `\` 归一为 `/`,去除前导 `./`) */
path: string;
/** 未压缩字节数 */
size: number;
/** 压缩方式:0=STORE,8=DEFLATE */
method: number;
/** 读出条目内容 */
read(): Buffer;
}
const EOCD_SIGNATURE = 0x06054b50;
const EOCD64_LOCATOR_SIGNATURE = 0x07064b50;
const EOCD64_SIGNATURE = 0x06064b50;
const CENTRAL_SIGNATURE = 0x02014b50;
const LOCAL_SIGNATURE = 0x04034b50;
const decoders = new Map<string, TextDecoder | null>();
function decoderFor(encoding: string): TextDecoder | null {
if (!decoders.has(encoding)) {
try {
decoders.set(encoding, new TextDecoder(encoding, { fatal: false }));
} catch {
decoders.set(encoding, null);
}
}
return decoders.get(encoding) ?? null;
}
/** 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码 */
const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/;
/** 解压包内文件名:UTF-8 标志位优先,否则试 Shift-JIS(中文/日文压缩包常见) */
function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string {
if (utf8Flag) return bytes.toString('utf8');
const hasHighByte = bytes.some((byte) => byte >= 0x80);
if (!hasHighByte) return bytes.toString('latin1');
const shiftJis = decoderFor('shift_jis');
if (shiftJis) {
const decoded = shiftJis.decode(bytes);
if (!decoded.includes('\uFFFD') && !HALF_WIDTH_KATAKANA.test(decoded)) return decoded;
}
const gbk = decoderFor('gbk');
if (gbk) {
const decoded = gbk.decode(bytes);
if (!decoded.includes('\uFFFD')) return decoded;
}
return bytes.toString('latin1');
}
/** 归一化条目路径,并拒绝绝对路径 / 上跳(zip-slip) */
function normalizeEntryPath(raw: string): string {
const unified = raw.replace(/\\/g, '/').replace(/^\.\//, '');
const parts: string[] = [];
for (const segment of unified.split('/')) {
if (segment === '' || segment === '.') continue;
if (segment === '..') throw new Error(`压缩包内存在上跳路径,已拒绝:${raw}`);
parts.push(segment);
}
if (unified.startsWith('/') || /^[A-Za-z]:/.test(unified)) {
throw new Error(`压缩包内存在绝对路径,已拒绝:${raw}`);
}
return parts.join('/');
}
function findEndOfCentralDirectory(buffer: Buffer): number {
// EOCD 最少 22 字节,注释最长 65535 字节
const minOffset = Math.max(0, buffer.length - 22 - 0xffff);
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
}
throw new Error('不是有效的 ZIP:找不到中央目录结束记录(EOCD)');
}
/** 解析 ZIP,返回条目列表(目录条目会被跳过) */
export function readZipEntries(buffer: Buffer): ZipEntry[] {
if (buffer.length < 22) throw new Error('不是有效的 ZIP:文件过小');
const eocd = findEndOfCentralDirectory(buffer);
const entryCount = buffer.readUInt16LE(eocd + 10);
let centralOffset = buffer.readUInt32LE(eocd + 16);
let centralSize = buffer.readUInt32LE(eocd + 12);
// ZIP64:条目数或偏移写满时,真实值在 ZIP64 EOCD 里
const needsZip64 = entryCount === 0xffff || centralOffset === 0xffffffff || centralSize === 0xffffffff;
if (needsZip64) {
const locator = eocd - 20;
if (locator < 0 || buffer.readUInt32LE(locator) !== EOCD64_LOCATOR_SIGNATURE) {
throw new Error('ZIP64 压缩包缺少 EOCD64 locator,无法解析');
}
const eocd64 = Number(buffer.readBigUInt64LE(locator + 8));
if (eocd64 < 0 || buffer.readUInt32LE(eocd64) !== EOCD64_SIGNATURE) {
throw new Error('ZIP64 EOCD 记录不合法');
}
const realCount = Number(buffer.readBigUInt64LE(eocd64 + 32));
centralSize = Number(buffer.readBigUInt64LE(eocd64 + 40));
centralOffset = Number(buffer.readBigUInt64LE(eocd64 + 48));
return readCentralDirectory(buffer, centralOffset, centralSize, realCount);
}
return readCentralDirectory(buffer, centralOffset, centralSize, entryCount);
}
function readCentralDirectory(
buffer: Buffer,
centralOffset: number,
centralSize: number,
entryCount: number,
): ZipEntry[] {
if (centralOffset + centralSize > buffer.length) {
throw new Error('ZIP 中央目录越界,文件可能已损坏或被截断');
}
const entries: ZipEntry[] = [];
let offset = centralOffset;
for (let i = 0; i < entryCount; i += 1) {
if (offset + 46 > buffer.length) throw new Error('ZIP 中央目录条目不完整');
if (buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) {
throw new Error(`ZIP 中央目录第 ${i + 1} 条记录签名不正确`);
}
const flags = buffer.readUInt16LE(offset + 8);
const method = buffer.readUInt16LE(offset + 10);
let compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const nameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
let localOffset = buffer.readUInt32LE(offset + 42);
const nameBytes = buffer.subarray(offset + 46, offset + 46 + nameLength);
const rawName = decodeEntryName(nameBytes, (flags & 0x0800) !== 0);
if (flags & 0x0001) throw new Error(`压缩包内条目已加密,无法读取:${rawName}`);
// ZIP64 扩展字段:本条目的大小/偏移写满时,真实值在这里
const extraStart = offset + 46 + nameLength;
let cursor = extraStart;
while (cursor + 4 <= extraStart + extraLength) {
const fieldId = buffer.readUInt16LE(cursor);
const fieldSize = buffer.readUInt16LE(cursor + 2);
if (fieldId === 0x0001) {
let p = cursor + 4;
if (uncompressedSize === 0xffffffff) p += 8;
if (compressedSize === 0xffffffff) {
compressedSize = Number(buffer.readBigUInt64LE(p));
p += 8;
}
if (localOffset === 0xffffffff) localOffset = Number(buffer.readBigUInt64LE(p));
break;
}
cursor += 4 + fieldSize;
}
const isDirectory = rawName.endsWith('/') || rawName.endsWith('\\');
if (!isDirectory) {
entries.push({
path: normalizeEntryPath(rawName),
size: uncompressedSize === 0xffffffff ? compressedSize : uncompressedSize,
method,
read: () => readEntry(buffer, localOffset, compressedSize, method, rawName),
});
}
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
function readEntry(
buffer: Buffer,
localOffset: number,
compressedSize: number,
method: number,
name: string,
): Buffer {
if (localOffset + 30 > buffer.length) throw new Error(`ZIP 局部头越界:${name}`);
if (buffer.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) {
throw new Error(`ZIP 局部头签名不正确:${name}`);
}
const nameLength = buffer.readUInt16LE(localOffset + 26);
const extraLength = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + nameLength + extraLength;
const dataEnd = dataStart + compressedSize;
if (dataEnd > buffer.length) throw new Error(`ZIP 条目数据越界(文件被截断):${name}`);
const raw = buffer.subarray(dataStart, dataEnd);
if (method === 0) return Buffer.from(raw);
if (method === 8) return inflateRawSync(raw);
throw new Error(`不支持的 ZIP 压缩方式 ${method}:${name}`);
}
/** 从条目列表里按路径取内容(大小写不敏感,兼容 Windows 压缩包) */
export function findZipEntry(entries: readonly ZipEntry[], path: string): ZipEntry | undefined {
const target = path.replace(/\\/g, '/').toLowerCase();
return entries.find((entry) => entry.path.toLowerCase() === target);
}
+35
View File
@@ -80,6 +80,37 @@ export class AppConfig {
return this.config.get<string>('assimpBin') ?? 'assimp';
}
/**
* 压缩包转换的目标格式。
* `fbx` 优先(按需求),但 Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件,
* 因此服务端在导出后回读校验,失败会自动回退 GLB 并记录 conversionNote。
*/
get modelConvertFormat(): 'fbx' | 'glb' {
return (this.config.get<string>('modelConvertFormat') ?? 'fbx') === 'glb' ? 'glb' : 'fbx';
}
/** 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 */
get modelConvertPmx(): string {
return this.config.get<string>('modelConvertPmx') ?? 'Hatsune Miku.pmx';
}
/** 单次模型转换超时(毫秒) */
get modelConvertTimeoutMs(): number {
return this.config.get<number>('modelConvertTimeoutMs') ?? 180_000;
}
/**
* 显式指定的默认模型 id(DEFAULT_MODEL_ID)。
* 未设置时按 `src/config/default-model.ts` 里写死的 DEFAULT_MODEL_SEED 匹配,
* 最后才回落到契约 data-models.md §4.1 的 VRM 优先规则。
*/
get defaultModelId(): number | null {
const raw = this.config.get<string>('defaultModelId');
if (raw === undefined || raw === null || raw === '') return null;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
get contractsDir(): string | undefined {
return this.config.get<string>('contractsDir') || undefined;
}
@@ -160,6 +191,10 @@ export const configuration = () => {
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'fbx',
modelConvertPmx: process.env.MODEL_CONVERT_PMX ?? 'Hatsune Miku.pmx',
modelConvertTimeoutMs: Number(process.env.MODEL_CONVERT_TIMEOUT_MS ?? 180_000),
defaultModelId: process.env.DEFAULT_MODEL_ID ?? '',
contractsDir: process.env.CONTRACTS_DIR ?? '',
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
+4 -4
View File
@@ -9,12 +9,12 @@ import { join } from 'node:path';
* 未设置该变量时回落到这里的常量)。
*/
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
'rest-api': '0.2.0',
'rest-api': '0.3.0',
'websocket-keypoints': '0.1.0',
'data-models': '0.2.0',
'data-models': '0.3.0',
'skeleton-topology': '0.1.0',
'model-formats': '0.2.0',
database: '0.1.0',
'model-formats': '0.3.0',
database: '0.2.0',
});
/** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */
+42
View File
@@ -0,0 +1,42 @@
/**
* 内置默认模型(**由服务端在压缩包转换成功后自动写入**)。
*
* 写入方:`ModelsService`(上传 zip 并转换成功时)与 `scripts/import-default-model.mjs`。
* 运行时只读:`ModelsService.resolveDefaultModelId()` 会先看这里,
* 再回落到契约 `data-models.md` §4.1 的 VRM 优先规则。
*
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
* 用转换产物的 sha256 才能稳定指回同一个模型。
*
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
*/
export interface DefaultModelSeed {
/** 数据库中的模型 id(仅作参考,换库后会变) */
id: number | null;
/** 模型名称 */
name: string;
/** 模型格式:fbx / glb / vrm */
format: string;
/** 转换产物文件的 sha256(定位默认模型的依据) */
sha256: string;
/** 转换来源说明,例如 `Hatsune Miku.pmx → GLB`;非转换模型为 null */
convertedFrom: string | null;
/** 最近一次写入时间(ISO 8601) */
updatedAt: string | null;
}
/** 由 Hatsune Miku.pmx → GLB 转换得到,已设为默认模型 */
export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
id: 16,
name: "初音未来 Hatsune Miku",
format: "glb",
sha256: "6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464",
convertedFrom: "Hatsune Miku.pmx → GLB",
updatedAt: "2026-09-15T05:45:19.168Z",
};
/** 该种子是否指向一个真实模型 */
export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean {
return seed.sha256.trim().length > 0;
}
+11
View File
@@ -38,6 +38,17 @@ export class CharacterModel {
@Column({ name: 'thumbnail_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber })
thumbnailAssetId!: number | null;
/**
* 转换来源:上传的压缩包(zip)所对应的 assets 记录。
* 只有"上传压缩包 → 服务端转换"的模型才有值,直接上传模型文件时为 null。
*/
@Column({ name: 'source_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber })
sourceAssetId!: number | null;
/** 转换说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`;未转换时为 null */
@Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true })
conversionNote!: string | null;
// ---- 来源五元组(必填)----
@Column({ name: 'source_type', type: 'varchar', length: 32 })
sourceType!: SourceType;
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* T-WEB-005 追加:模型压缩包转换支持。
*
* - `models.source_asset_id`:上传的压缩包(zip)对应的 assets 记录,便于溯源
* - `models.conversion_note`:转换过程说明(pmx → fbx/glb、贴图内嵌情况、回退原因)
*
* 两列都可空,对既有数据无影响。
*/
export class AddModelConversionFields1789450000000 implements MigrationInterface {
name = 'AddModelConversionFields1789450000000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`models\` ADD \`source_asset_id\` bigint NULL`);
await queryRunner.query(`ALTER TABLE \`models\` ADD \`conversion_note\` varchar(500) NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`conversion_note\``);
await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`source_asset_id\``);
}
}
@@ -0,0 +1,98 @@
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { DefaultModelSeed } from '../config/default-model';
/**
* 把默认模型写进源码(`web-server/src/config/default-model.ts`)。
*
* 需求原文是"转换成功则把这个模型设置为默认模型写进源码中",因此这里不是只改数据库,
* 而是把种子(名称 / 格式 / 产物 sha256)落进源码文件,随代码一起提交、一起部署。
*
* 注意:生产环境 systemd 单元带 `ProtectSystem=strict`,服务进程**没有**程序目录的写权限,
* 因此写入通常由 `scripts/import-default-model.mjs`(在服务沙箱之外、以 root 运行)完成;
* 服务内联转换成功时若能写就写,写不了就只告警,不阻断上传。
*/
function quote(value: string): string {
return JSON.stringify(value);
}
/** 渲染 `default-model.ts` 的内容 */
export function renderDefaultModelSource(seed: DefaultModelSeed): string {
return `/**
* 内置默认模型(**由服务端在压缩包转换成功后自动写入**)。
*
* 写入方:\`ModelsService\`(上传 zip 并转换成功时)与 \`scripts/import-default-model.mjs\`。
* 运行时只读:\`ModelsService.resolveDefaultModelId()\` 会先看这里,
* 再回落到契约 \`data-models.md\` §4.1 的 VRM 优先规则。
*
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
* 用转换产物的 sha256 才能稳定指回同一个模型。
*
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
*/
export interface DefaultModelSeed {
/** 数据库中的模型 id(仅作参考,换库后会变) */
id: number | null;
/** 模型名称 */
name: string;
/** 模型格式:fbx / glb / vrm */
format: string;
/** 转换产物文件的 sha256(定位默认模型的依据) */
sha256: string;
/** 转换来源说明,例如 \`Hatsune Miku.pmx → GLB\`;非转换模型为 null */
convertedFrom: string | null;
/** 最近一次写入时间(ISO 8601) */
updatedAt: string | null;
}
/** 由 ${seed.convertedFrom ?? '手工指定'} 转换得到,已设为默认模型 */
export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
id: ${seed.id === null ? 'null' : seed.id},
name: ${quote(seed.name)},
format: ${quote(seed.format)},
sha256: ${quote(seed.sha256)},
convertedFrom: ${seed.convertedFrom === null ? 'null' : quote(seed.convertedFrom)},
updatedAt: ${seed.updatedAt === null ? 'null' : quote(seed.updatedAt)},
};
/** 该种子是否指向一个真实模型 */
export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean {
return seed.sha256.trim().length > 0;
}
`;
}
/** 源码文件路径:优先 `web-server/src/config/default-model.ts`(相对当前工作目录) */
export function defaultModelSourcePath(cwd = process.cwd()): string {
const candidates = [
join(cwd, 'src', 'config', 'default-model.ts'),
resolve(cwd, '..', 'src', 'config', 'default-model.ts'),
];
return candidates.find((path) => existsSync(path)) ?? candidates[0];
}
export interface WriteSeedResult {
written: boolean;
path: string;
reason?: string;
}
/** 写入默认模型种子;写不了时返回原因而不是抛错(上传不应因此失败) */
export async function writeDefaultModelSeed(
seed: DefaultModelSeed,
targetPath = defaultModelSourcePath(),
): Promise<WriteSeedResult> {
try {
await writeFile(targetPath, renderDefaultModelSource(seed), 'utf8');
return { written: true, path: targetPath };
} catch (error) {
return {
written: false,
path: targetPath,
reason: error instanceof Error ? error.message : String(error),
};
}
}
+156
View File
@@ -0,0 +1,156 @@
import { extname } from 'node:path';
/**
* 把 GLB 里以外部文件形式引用的贴图**内嵌**进二进制块。
*
* 为什么需要:模型库里一条记录只对应一个文件,客户端拿到的必须能独立打开。
* Assimp 的 glTF2 导出器在 GLB 里仍然把贴图写成 `textures\\xxx.png` 这样的外部
* URI(还带反斜杠,本身就不符合 glTF 规范),不转换的话加载出来是白模。
*
* 处理方式:解析 GLB 的 JSON / BIN 两个 chunk,为每个外部贴图追加 bufferView,
* 把 `uri` 换成 `bufferView + mimeType`,再按 glTF 的 4 字节对齐规则重新拼装。
*/
const GLB_MAGIC = 0x46546c67;
const CHUNK_JSON = 0x4e4f534a;
const CHUNK_BIN = 0x004e4942;
export interface GlbEmbedResult {
glb: Buffer;
/** 成功内嵌的贴图数量 */
embedded: number;
/** 找不到文件或格式不支持、仍保留 uri 的贴图 */
unresolved: string[];
}
interface GltfJson {
asset?: Record<string, unknown>;
buffers?: Array<{ byteLength?: number; uri?: string }>;
bufferViews?: Array<Record<string, unknown>>;
images?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
const MIME_BY_EXTENSION: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.ktx2': 'image/ktx2',
'.basis': 'image/basis',
};
function align4(value: number): number {
return value + ((4 - (value % 4)) % 4);
}
function parseGlb(buffer: Buffer): { json: GltfJson; bin: Buffer | null } {
if (buffer.length < 12 || buffer.readUInt32LE(0) !== GLB_MAGIC) {
throw new Error('不是有效的 GLB:魔数不正确');
}
const totalLength = buffer.readUInt32LE(8);
const end = Math.min(totalLength || buffer.length, buffer.length);
let offset = 12;
let json: GltfJson | null = null;
let bin: Buffer | null = null;
while (offset + 8 <= end) {
const chunkLength = buffer.readUInt32LE(offset);
const chunkType = buffer.readUInt32LE(offset + 4);
const dataStart = offset + 8;
if (dataStart + chunkLength > buffer.length) break;
const data = buffer.subarray(dataStart, dataStart + chunkLength);
if (chunkType === CHUNK_JSON) json = JSON.parse(data.toString('utf8')) as GltfJson;
else if (chunkType === CHUNK_BIN) bin = Buffer.from(data);
offset = dataStart + align4(chunkLength);
}
if (!json) throw new Error('GLB 里没有 JSON chunk');
return { json, bin };
}
function buildGlb(json: GltfJson, bin: Buffer | null): Buffer {
const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20);
jsonBuffer.copy(jsonPadded);
const chunks: Buffer[] = [];
const header = Buffer.alloc(12);
header.writeUInt32LE(GLB_MAGIC, 0);
header.writeUInt32LE(2, 4);
chunks.push(header);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(jsonPadded.length, 0);
jsonHeader.writeUInt32LE(CHUNK_JSON, 4);
chunks.push(jsonHeader, jsonPadded);
if (bin && bin.length > 0) {
const binPadded = Buffer.alloc(align4(bin.length));
bin.copy(binPadded);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binPadded.length, 0);
binHeader.writeUInt32LE(CHUNK_BIN, 4);
chunks.push(binHeader, binPadded);
}
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = Buffer.concat(chunks, total);
out.writeUInt32LE(total, 8);
return out;
}
/**
* 内嵌 GLB 中的外部贴图。
*
* @param buffer GLB 内容
* @param resolve 把 glTF 里的 uri 映射为文件内容;返回 null 表示找不到
*/
export function embedGlbImages(
buffer: Buffer,
resolve: (uri: string) => Buffer | null,
): GlbEmbedResult {
const { json, bin } = parseGlb(buffer);
const images = json.images ?? [];
if (images.length === 0) return { glb: buffer, embedded: 0, unresolved: [] };
const views = json.bufferViews ?? [];
const chunks: Buffer[] = bin && bin.length > 0 ? [bin] : [];
let offset = bin ? bin.length : 0;
let embedded = 0;
const unresolved: string[] = [];
for (const image of images) {
const uri = typeof image.uri === 'string' ? image.uri : null;
if (!uri || uri.startsWith('data:')) continue;
const file = resolve(uri);
if (!file) {
unresolved.push(uri);
continue;
}
const mimeType = MIME_BY_EXTENSION[extname(uri).toLowerCase()];
if (!mimeType) {
unresolved.push(uri);
continue;
}
const padding = align4(offset) - offset;
if (padding > 0) {
chunks.push(Buffer.alloc(padding));
offset += padding;
}
chunks.push(file);
views.push({ buffer: 0, byteOffset: offset, byteLength: file.length });
image.bufferView = views.length - 1;
image.mimeType = mimeType;
delete image.uri;
offset += file.length;
embedded += 1;
}
if (embedded === 0) return { glb: buffer, embedded: 0, unresolved };
const merged = Buffer.concat(chunks, offset);
json.bufferViews = views;
json.buffers = [{ byteLength: merged.length }];
return { glb: buildGlb(json, merged), embedded, unresolved };
}
+215
View File
@@ -0,0 +1,215 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, extname, join } from 'node:path';
import { promisify } from 'node:util';
import { ModelFormat } from '../common/enums';
import { ZipEntry, readZipEntries } from '../common/zip-reader';
import { embedGlbImages } from './glb-textures';
const run = promisify(execFile);
/**
* 压缩包 → 可入库模型文件的转换。
*
* 典型场景:MMD 模型包(zip 内含 `xxx.pmx` + `textures/`)→ 解压 → 选主模型
* → 调 Assimp 导出 FBX;**Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件**
* (实测 Miku PMX:导出退出码 11,产出的 FBX 回读报 `FBX-DOM no Objects dictionary found`),
* 因此导出后必须回读校验,失败则回退 GLB,并把过程写进 conversionNote。
*
* 转换后的 GLB 还会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,
* 保证模型库里一个文件就能独立加载(否则是白模)。
*/
export interface ConvertOptions {
/** Assimp 可执行文件(默认 `assimp`,可用 ASSIMP_BIN 覆盖) */
assimpBin: string;
/** 单次转换超时(毫秒) */
timeoutMs?: number;
/** 优先尝试的格式 */
preferFormat?: 'fbx' | 'glb';
/** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */
preferredPmx?: string;
}
export interface ConvertedModel {
/** 转换产物内容 */
buffer: Buffer;
/** 产物格式(fbx 或 glb) */
format: ModelFormat;
/** 实际使用的 pmx 相对路径 */
sourceModelPath: string;
/** 人类可读的转换说明,写入 models.conversion_note */
note: string;
/** 逐步尝试记录,便于排障 */
attempts: string[];
}
const DEFAULT_TIMEOUT_MS = 180_000;
/** 选择主模型:优先指名字,其次目录最浅,最后文件最大 */
export function pickModelFile(
entries: readonly ZipEntry[],
preferred?: string,
): ZipEntry | undefined {
const models = entries.filter((entry) => extname(entry.path).toLowerCase() === '.pmx');
if (models.length === 0) return undefined;
const wanted = preferred?.trim().toLowerCase();
if (wanted) {
const exact = models.find((entry) => entry.path.toLowerCase() === wanted);
if (exact) return exact;
const byBase = models.find(
(entry) => entry.path.split('/').pop()?.toLowerCase() === wanted,
);
if (byBase) return byBase;
}
return [...models].sort((a, b) => {
const depthA = a.path.split('/').length;
const depthB = b.path.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return b.size - a.size;
})[0];
}
/** 解压到工作目录,同时保留内容供贴图内嵌使用 */
async function unpack(
entries: readonly ZipEntry[],
targetDir: string,
): Promise<Map<string, Buffer>> {
const contents = new Map<string, Buffer>();
for (const entry of entries) {
const data = entry.read();
contents.set(entry.path, data);
const destination = join(targetDir, entry.path);
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, data);
}
return contents;
}
/** 在解压结果里按 glTF 的 uri 找贴图(容错反斜杠与大小写) */
function makeResolver(contents: Map<string, Buffer>) {
const lowered = new Map<string, Buffer>();
for (const [path, data] of contents) lowered.set(path.replace(/\\/g, '/').toLowerCase(), data);
return (uri: string): Buffer | null => {
if (uri.startsWith('data:')) return null;
let decoded = uri;
try {
decoded = decodeURIComponent(uri);
} catch {
/* 保持原样 */
}
const normalized = decoded.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
const direct = lowered.get(normalized);
if (direct) return direct;
const base = normalized.split('/').pop();
if (!base) return null;
for (const [path, data] of lowered) {
if (path.split('/').pop() === base) return data;
}
return null;
};
}
/** 用 Assimp 导出到指定格式,并回读校验(回读失败视为转换失败) */
async function exportAndValidate(
assimpBin: string,
modelPath: string,
outPath: string,
timeoutMs: number,
): Promise<{ ok: boolean; detail: string }> {
try {
await run(assimpBin, ['export', modelPath, outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 });
} catch (error) {
const err = error as { code?: number | string; stderr?: string; message?: string };
const detail = err.stderr?.trim() || err.message || String(error);
return { ok: false, detail: `导出失败(${err.code ?? 'unknown'}):${detail.slice(0, 300)}` };
}
let size = 0;
try {
size = (await readFile(outPath)).length;
} catch {
return { ok: false, detail: '导出命令返回成功但没有产出文件' };
}
if (size === 0) return { ok: false, detail: '导出产物为空文件' };
// 关键校验:Assimp 的 FBX 导出器会"写出一个坏文件但退出码非 0",
// 必须用 info 回读确认,否则会把损坏文件入库。
try {
await run(assimpBin, ['info', outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 });
} catch (error) {
const err = error as { stderr?: string; message?: string };
const detail = err.stderr?.trim() || err.message || String(error);
return { ok: false, detail: `产物无法回读(已判定损坏):${detail.slice(0, 300)}` };
}
return { ok: true, detail: `产物 ${size} 字节,回读校验通过` };
}
/** 压缩包 → 模型文件(FBX 优先,失败回退 GLB) */
export async function convertModelArchive(
archive: Buffer,
options: ConvertOptions,
): Promise<ConvertedModel> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const prefer = options.preferFormat ?? 'fbx';
const entries = readZipEntries(archive);
if (entries.length === 0) throw new Error('压缩包是空的');
const modelEntry = pickModelFile(entries, options.preferredPmx);
if (!modelEntry) {
throw new Error('压缩包里没有找到 .pmx 模型文件(当前只支持 MMD 的 PMX 模型包)');
}
const workDir = await mkdtemp(join(tmpdir(), 'opendance-model-'));
const attempts: string[] = [];
try {
const contents = await unpack(entries, workDir);
const modelPath = join(workDir, modelEntry.path);
const order: Array<'fbx' | 'glb'> =
prefer === 'glb' ? ['glb', 'fbx'] : ['fbx', 'glb'];
for (const target of order) {
const outPath = join(workDir, `converted.${target}`);
const result = await exportAndValidate(options.assimpBin, modelPath, outPath, timeoutMs);
attempts.push(`${target.toUpperCase()}:${result.detail}`);
if (!result.ok) continue;
let buffer: Buffer = await readFile(outPath);
let extra = '';
if (target === 'glb') {
// 贴图内嵌是"锦上添花":即便结构不符合预期也不能让整次转换失败
try {
const embedded = embedGlbImages(buffer, makeResolver(contents));
buffer = Buffer.from(embedded.glb);
if (embedded.embedded > 0) extra = `,已内嵌 ${embedded.embedded} 张贴图`;
if (embedded.unresolved.length > 0) {
extra += `(${embedded.unresolved.length} 张贴图未找到:${embedded.unresolved
.slice(0, 3)
.join('、')})`;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
extra = `(贴图内嵌跳过:${message.slice(0, 120)})`;
}
}
const note = `${modelEntry.path} → ${target.toUpperCase()}${extra}`;
return {
buffer,
format: target === 'fbx' ? ModelFormat.Fbx : ModelFormat.Glb,
sourceModelPath: modelEntry.path,
note,
attempts,
};
}
throw new Error(
`模型转换失败:${attempts.join(';')}。` +
'请确认服务器已安装 Assimp(ASSIMP_BIN),或直接上传 .glb / .vrm 文件。',
);
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
@@ -10,7 +10,11 @@ export interface ModelDto {
isPrimary: boolean;
fileUrl: string | null;
fileSize: number | null;
/** 模型文件(转换产物)的 sha256,用于换库后定位默认模型 */
fileSha256: string | null;
thumbnailUrl: string | null;
/** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */
conversionNote: string | null;
sourceType: SourceType;
sourceName: string;
sourceUrl: string;
@@ -25,6 +29,7 @@ export interface ModelUrlLookup {
fileUrl: string | null;
thumbnailUrl: string | null;
fileSize?: number | null;
fileSha256?: string | null;
}
export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto {
@@ -36,7 +41,9 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt
isPrimary: model.isPrimary,
fileUrl: urls.fileUrl,
fileSize: urls.fileSize ?? null,
fileSha256: urls.fileSha256 ?? null,
thumbnailUrl: urls.thumbnailUrl,
conversionNote: model.conversionNote ?? null,
sourceType: model.sourceType,
sourceName: model.sourceName,
sourceUrl: model.sourceUrl,
+173 -4
View File
@@ -1,5 +1,14 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { readFile } from 'node:fs/promises';
import { extname } from 'node:path';
import { DataSource, Repository } from 'typeorm';
import {
AssetKind,
@@ -16,7 +25,11 @@ import {
requireUploadedFile,
resolveFormatByExtension,
} from '../common/upload.util';
import { DEFAULT_MODEL_SEED } from '../config/default-model';
import { writeDefaultModelSeed } from './default-model-source';
import { convertModelArchive } from './model-convert';
import { CharacterModel } from '../entities/model.entity';
import { AppConfig } from '../config/configuration';
import { StorageService } from '../storage/storage.service';
import { QueryModelDto, UpdateModelDto } from './model.dto';
import { ModelDto, toModelDto } from './model.serializer';
@@ -24,10 +37,13 @@ import { inspectVrm } from './vrm-inspector';
@Injectable()
export class ModelsService {
private readonly logger = new Logger(ModelsService.name);
constructor(
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
private readonly storage: StorageService,
private readonly dataSource: DataSource,
private readonly config: AppConfig,
) {}
async list(query: QueryModelDto): Promise<Paged<ModelDto>> {
@@ -63,8 +79,6 @@ export class ModelsService {
if (!name) throw new BadRequestException('name 不能为空');
if (name.length > 200) throw new BadRequestException('name 长度不能超过 200');
const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件');
const tuple = assertSourceTupleComplete({
sourceType: body.sourceType as SourceTuple['sourceType'],
sourceName: body.sourceName as string,
@@ -76,6 +90,13 @@ export class ModelsService {
throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)');
}
// 压缩包(MMD 模型包)走"解压 → 转换 → 入库"通路,其它扩展名走原有的直传通路
if (extname(upload.originalname).toLowerCase() === '.zip') {
return this.createFromArchive(body, upload, name, tuple);
}
const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件');
const stored = await this.storage.persistTempFile(
AssetKind.Model,
upload.path,
@@ -122,6 +143,126 @@ export class ModelsService {
return dto;
}
/**
* T-WEB-005 追加:上传压缩包(如 MMD 的 `模型_xxx.zip`)→ 解压选 PMX → Assimp 转换
* → 入库。FBX 优先,导出后回读校验,失败自动回退 GLB 并记录 conversion_note。
*
* 转换成功后默认把它设为**全局默认模型**(is_primary + published),并把身份
* (名称/格式/产物 sha256)写进源码 `src/config/default-model.ts`;
* 传 `autoDefault=false` 可只入库不改默认。
*/
private async createFromArchive(
body: Record<string, unknown>,
upload: UploadedFileLike,
name: string,
tuple: NonNullable<ReturnType<typeof assertSourceTupleComplete>>,
): Promise<ModelDto> {
if (upload.size && upload.size > this.config.uploadMaxBytes) {
throw new BadRequestException(
`压缩包超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
);
}
const archive = await readFile(upload.path);
let converted;
try {
converted = await convertModelArchive(archive, {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: this.config.modelConvertFormat,
preferredPmx: this.config.modelConvertPmx,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`模型压缩包转换失败(${upload.originalname}):${message}`);
throw new UnprocessableEntityException(`模型转换失败:${message}`);
}
// 原件留存用于溯源(按 sha256 去重)
const archiveAsset = await this.storage.persistBuffer(
AssetKind.ModelArchive,
archive,
upload.originalname,
upload.mimetype || 'application/zip',
);
const stored = await this.storage.persistBuffer(
AssetKind.Model,
converted.buffer,
`${name}.${converted.format}`,
converted.format === ModelFormat.Glb ? 'model/gltf-binary' : 'application/octet-stream',
);
// 转换产物若是 GLB,顺带识别是否带 VRM 扩展
let vrmVersion: string | null = null;
if (converted.format === ModelFormat.Glb) {
try {
vrmVersion = (await inspectVrm(this.storage.absolutePath(stored.asset))).vrmVersion;
} catch {
vrmVersion = null;
}
}
const autoDefault = body.autoDefault === undefined ? true : parseOptionalBool(body.autoDefault);
const status = autoDefault ? PublishStatus.Published : this.parseStatus(body.status);
const model = this.models.create({
name,
format: converted.format,
vrmVersion,
isPrimary: false,
fileAssetId: stored.asset.id,
sourceAssetId: archiveAsset.asset.id,
conversionNote: converted.note.slice(0, 500),
sourceType: tuple.sourceType as CharacterModel['sourceType'],
sourceName: tuple.sourceName,
sourceUrl: tuple.sourceUrl,
license: tuple.license,
author: tuple.author,
status,
});
const saved = await this.models.save(model);
this.logger.log(
`模型压缩包已转换入库:#${saved.id} ${name}(${converted.note})` +
`;尝试记录:${converted.attempts.join(';')}`,
);
if (autoDefault) {
await this.applyDefaultModel(saved.id, stored.asset.sha256, converted.note);
// setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确
const [dto] = await this.toDtos([await this.getEntity(saved.id)]);
return dto;
}
const [dto] = await this.toDtos([saved]);
return dto;
}
/**
* 设为默认模型并把种子写进源码。写入失败只告警:生产环境 systemd 的
* `ProtectSystem=strict` 不允许服务进程改程序目录,通常由
* `scripts/import-default-model.mjs` 在沙箱外完成写入。
*/
private async applyDefaultModel(id: number, sha256: string, note: string): Promise<void> {
await this.setPrimary(id);
const model = await this.getEntity(id);
const result = await writeDefaultModelSeed({
id,
name: model.name,
format: model.format,
sha256,
convertedFrom: note.split(',')[0] ?? null,
updatedAt: new Date().toISOString(),
});
if (result.written) {
this.logger.log(`默认模型已写进源码:${result.path}`);
} else {
this.logger.warn(
`默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` +
`可在源码目录执行 node scripts/import-default-model.mjs --write-source-only 补写`,
);
}
}
async update(id: number, dto: UpdateModelDto): Promise<ModelDto> {
const model = await this.getEntity(id);
@@ -170,7 +311,16 @@ export class ModelsService {
return dto;
}
/** 客户端默认模型解析 —— 契约 data-models.md §4.1 的 2~4 步 */
/**
* 客户端默认模型解析 —— 契约 data-models.md §4.1。
*
* 顺序:
* 0. 环境变量 `DEFAULT_MODEL_ID` 显式指定(且模型存在)
* 1. 源码里写死的 `DEFAULT_MODEL_SEED`(按转换产物 sha256 匹配,换库也能定位)
* 2. 已发布且 is_primary 的 VRM
* 3. 任意已发布 VRM
* 4. id 最小的已发布模型
*/
async resolveDefaultModelId(): Promise<number | null> {
const published = await this.models.find({
where: { status: PublishStatus.Published },
@@ -178,6 +328,24 @@ export class ModelsService {
});
if (published.length === 0) return null;
const configured = this.config.defaultModelId;
if (configured !== null) {
const hit = published.find((m) => m.id === configured);
if (hit) return hit.id;
this.logger.warn(`DEFAULT_MODEL_ID=${configured} 指向的模型不存在或未发布,已忽略`);
}
const seedSha = DEFAULT_MODEL_SEED.sha256.trim();
if (seedSha) {
const assets = await this.storage.findMany(published.map((m) => m.fileAssetId));
const seeded = published.find((m) => {
const asset = m.fileAssetId === null ? null : assets.get(m.fileAssetId);
return asset?.sha256 === seedSha;
});
if (seeded) return seeded.id;
this.logger.warn('源码里的默认模型种子在内库中找不到对应模型,回落到 VRM 优先规则');
}
const primaryVrm = published.find((m) => m.format === ModelFormat.Vrm && m.isPrimary);
if (primaryVrm) return primaryVrm.id;
@@ -206,6 +374,7 @@ export class ModelsService {
fileUrl: file ? this.storage.url(file) : null,
thumbnailUrl: thumb ? this.storage.url(thumb) : null,
fileSize: file?.size ?? null,
fileSha256: file?.sha256 ?? null,
});
});
}