feat(web): T-WEB-005 模型上传 + VRM 优先 + 版本识别
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ModelFormat, PublishStatus } from '../common/enums';
|
||||||
|
import { PageQueryDto } from '../common/pagination';
|
||||||
|
import { PartialSourceFieldsDto } from '../common/source.dto';
|
||||||
|
|
||||||
|
export class UpdateModelDto extends PartialSourceFieldsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PublishStatus)
|
||||||
|
status?: PublishStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryModelDto extends PageQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ModelFormat)
|
||||||
|
format?: ModelFormat;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PublishStatus)
|
||||||
|
status?: PublishStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
primaryOnly?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ModelFormat, PublishStatus, SourceType } from '../common/enums';
|
||||||
|
import { CharacterModel } from '../entities/model.entity';
|
||||||
|
|
||||||
|
/** 契约 data-models.md §4 的对外表示 */
|
||||||
|
export interface ModelDto {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
format: ModelFormat;
|
||||||
|
vrmVersion: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
fileUrl: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
sourceType: SourceType;
|
||||||
|
sourceName: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
license: string;
|
||||||
|
author: string;
|
||||||
|
status: PublishStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelUrlLookup {
|
||||||
|
fileUrl: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto {
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
format: model.format,
|
||||||
|
vrmVersion: model.vrmVersion,
|
||||||
|
isPrimary: model.isPrimary,
|
||||||
|
fileUrl: urls.fileUrl,
|
||||||
|
fileSize: urls.fileSize ?? null,
|
||||||
|
thumbnailUrl: urls.thumbnailUrl,
|
||||||
|
sourceType: model.sourceType,
|
||||||
|
sourceName: model.sourceName,
|
||||||
|
sourceUrl: model.sourceUrl,
|
||||||
|
license: model.license,
|
||||||
|
author: model.author,
|
||||||
|
status: model.status,
|
||||||
|
createdAt: model.createdAt.toISOString(),
|
||||||
|
updatedAt: model.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { Paged } from '../common/pagination';
|
||||||
|
import { UploadedFileLike } from '../common/upload.util';
|
||||||
|
import { QueryModelDto, UpdateModelDto } from './model.dto';
|
||||||
|
import { ModelDto } from './model.serializer';
|
||||||
|
import { ModelsService } from './models.service';
|
||||||
|
|
||||||
|
@Controller('api/models')
|
||||||
|
export class ModelsController {
|
||||||
|
constructor(private readonly models: ModelsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: QueryModelDto): Promise<Paged<ModelDto>> {
|
||||||
|
return this.models.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** multipart/form-data:file + name + 来源五元组 + isPrimary/status */
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
create(
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
): Promise<ModelDto> {
|
||||||
|
return this.models.create(body, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<ModelDto> {
|
||||||
|
return this.models.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateModelDto): Promise<ModelDto> {
|
||||||
|
return this.models.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.models.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/primary')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
setPrimary(@Param('id', ParseIntPipe) id: number): Promise<ModelDto> {
|
||||||
|
return this.models.setPrimary(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CharacterModel } from '../entities/model.entity';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { ModelsController } from './models.controller';
|
||||||
|
import { ModelsService } from './models.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([CharacterModel]), StorageModule],
|
||||||
|
controllers: [ModelsController],
|
||||||
|
providers: [ModelsService],
|
||||||
|
exports: [ModelsService],
|
||||||
|
})
|
||||||
|
export class ModelsModule {}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AssetKind,
|
||||||
|
MODEL_EXTENSION_MAP,
|
||||||
|
ModelFormat,
|
||||||
|
PublishStatus,
|
||||||
|
canTransition,
|
||||||
|
} from '../common/enums';
|
||||||
|
import { Paged, toPaged } from '../common/pagination';
|
||||||
|
import { SourceTuple, assertSourceTupleComplete } from '../common/source.dto';
|
||||||
|
import {
|
||||||
|
UploadedFileLike,
|
||||||
|
parseOptionalBool,
|
||||||
|
requireUploadedFile,
|
||||||
|
resolveFormatByExtension,
|
||||||
|
} from '../common/upload.util';
|
||||||
|
import { CharacterModel } from '../entities/model.entity';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import { QueryModelDto, UpdateModelDto } from './model.dto';
|
||||||
|
import { ModelDto, toModelDto } from './model.serializer';
|
||||||
|
import { inspectVrm } from './vrm-inspector';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ModelsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: QueryModelDto): Promise<Paged<ModelDto>> {
|
||||||
|
const qb = this.models.createQueryBuilder('m').orderBy('m.id', 'DESC');
|
||||||
|
if (query.status) qb.andWhere('m.status = :status', { status: query.status });
|
||||||
|
if (query.format) qb.andWhere('m.format = :format', { format: query.format });
|
||||||
|
if (query.primaryOnly) qb.andWhere('m.is_primary = 1');
|
||||||
|
if (query.q) {
|
||||||
|
const pattern = `%${query.q.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
||||||
|
qb.andWhere('(m.name LIKE :pattern OR m.author LIKE :pattern)', { pattern });
|
||||||
|
}
|
||||||
|
const [rows, total] = await qb.skip(query.skip).take(query.pageSize).getManyAndCount();
|
||||||
|
return toPaged(await this.toDtos(rows), total, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number): Promise<ModelDto> {
|
||||||
|
const model = await this.getEntity(id);
|
||||||
|
const [dto] = await this.toDtos([model]);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntity(id: number): Promise<CharacterModel> {
|
||||||
|
const model = await this.models.findOne({ where: { id } });
|
||||||
|
if (!model) throw new NotFoundException(`模型 #${id} 不存在`);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T-WEB-005:上传模型 + 来源字段,VRM 优先;自动识别 VRM 版本 */
|
||||||
|
async create(body: Record<string, unknown>, file: UploadedFileLike | undefined): Promise<ModelDto> {
|
||||||
|
const upload = requireUploadedFile(file);
|
||||||
|
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
||||||
|
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,
|
||||||
|
sourceUrl: body.sourceUrl as string,
|
||||||
|
license: body.license as string,
|
||||||
|
author: body.author as string,
|
||||||
|
});
|
||||||
|
if (!tuple) {
|
||||||
|
throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await this.storage.persistTempFile(
|
||||||
|
AssetKind.Model,
|
||||||
|
upload.path,
|
||||||
|
upload.originalname,
|
||||||
|
upload.mimetype,
|
||||||
|
);
|
||||||
|
|
||||||
|
// VRM 版本识别(GLB 容器内检查 VRM / VRMC_vrm 扩展)
|
||||||
|
let vrmVersion: string | null = null;
|
||||||
|
if (format === ModelFormat.Vrm || format === ModelFormat.Glb) {
|
||||||
|
try {
|
||||||
|
const inspection = await inspectVrm(this.storage.absolutePath(stored.asset));
|
||||||
|
vrmVersion = inspection.vrmVersion;
|
||||||
|
} catch {
|
||||||
|
vrmVersion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (format === ModelFormat.Vrm && !vrmVersion) {
|
||||||
|
vrmVersion = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = this.parseStatus(body.status);
|
||||||
|
const model = this.models.create({
|
||||||
|
name,
|
||||||
|
format: format as ModelFormat,
|
||||||
|
vrmVersion,
|
||||||
|
isPrimary: false,
|
||||||
|
fileAssetId: stored.asset.id,
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (parseOptionalBool(body.isPrimary)) {
|
||||||
|
return this.setPrimary(saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dto] = await this.toDtos([saved]);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateModelDto): Promise<ModelDto> {
|
||||||
|
const model = await this.getEntity(id);
|
||||||
|
|
||||||
|
if (dto.status && dto.status !== model.status && !canTransition(model.status, dto.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`状态不允许从 ${model.status} 直接变更为 ${dto.status},请使用 /api/reviews 接口`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tuple = assertSourceTupleComplete({
|
||||||
|
sourceType: dto.sourceType,
|
||||||
|
sourceName: dto.sourceName,
|
||||||
|
sourceUrl: dto.sourceUrl,
|
||||||
|
license: dto.license,
|
||||||
|
author: dto.author,
|
||||||
|
});
|
||||||
|
if (tuple) Object.assign(model, tuple);
|
||||||
|
|
||||||
|
if (dto.name !== undefined) model.name = dto.name.trim();
|
||||||
|
if (dto.status !== undefined) model.status = dto.status;
|
||||||
|
|
||||||
|
const saved = await this.models.save(model);
|
||||||
|
const [result] = await this.toDtos([saved]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
const model = await this.getEntity(id);
|
||||||
|
await this.models.softRemove(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置全局默认模型。
|
||||||
|
* 契约 data-models.md §4.1:同一事务内先清零其余,保证互斥。
|
||||||
|
*/
|
||||||
|
async setPrimary(id: number): Promise<ModelDto> {
|
||||||
|
const model = await this.getEntity(id);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.update(CharacterModel, { isPrimary: true }, { isPrimary: false });
|
||||||
|
await manager.update(CharacterModel, { id }, { isPrimary: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
model.isPrimary = true;
|
||||||
|
const [dto] = await this.toDtos([model]);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户端默认模型解析 —— 契约 data-models.md §4.1 的 2~4 步 */
|
||||||
|
async resolveDefaultModelId(): Promise<number | null> {
|
||||||
|
const published = await this.models.find({
|
||||||
|
where: { status: PublishStatus.Published },
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
if (published.length === 0) return null;
|
||||||
|
|
||||||
|
const primaryVrm = published.find((m) => m.format === ModelFormat.Vrm && m.isPrimary);
|
||||||
|
if (primaryVrm) return primaryVrm.id;
|
||||||
|
|
||||||
|
const anyVrm = published.find((m) => m.format === ModelFormat.Vrm);
|
||||||
|
if (anyVrm) return anyVrm.id;
|
||||||
|
|
||||||
|
return published[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStatus(value: unknown): PublishStatus {
|
||||||
|
if (typeof value !== 'string' || value === '') return PublishStatus.Draft;
|
||||||
|
if (!Object.values(PublishStatus).includes(value as PublishStatus)) {
|
||||||
|
throw new BadRequestException(`status 非法:${value}`);
|
||||||
|
}
|
||||||
|
return value as PublishStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toDtos(rows: CharacterModel[]): Promise<ModelDto[]> {
|
||||||
|
const assets = await this.storage.findMany(
|
||||||
|
rows.flatMap((row) => [row.fileAssetId, row.thumbnailAssetId]),
|
||||||
|
);
|
||||||
|
return rows.map((row) => {
|
||||||
|
const file = row.fileAssetId ? assets.get(row.fileAssetId) : undefined;
|
||||||
|
const thumb = row.thumbnailAssetId ? assets.get(row.thumbnailAssetId) : undefined;
|
||||||
|
return toModelDto(row, {
|
||||||
|
fileUrl: file ? this.storage.url(file) : null,
|
||||||
|
thumbnailUrl: thumb ? this.storage.url(thumb) : null,
|
||||||
|
fileSize: file?.size ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
|
||||||
|
export interface VrmInspection {
|
||||||
|
isGlb: boolean;
|
||||||
|
vrmVersion: string | null;
|
||||||
|
generator: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLB_MAGIC = 0x46546c67; // 'glTF'
|
||||||
|
const CHUNK_JSON = 0x4e4f534a; // 'JSON'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 GLB/VRM 文件头,识别 VRM 版本。
|
||||||
|
* VRM 0.x 使用 `extensions.VRM`,VRM 1.0 使用 `extensions.VRMC_vrm`。
|
||||||
|
*/
|
||||||
|
export async function inspectVrm(path: string): Promise<VrmInspection> {
|
||||||
|
const handle = await open(path, 'r');
|
||||||
|
try {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
const { bytesRead } = await handle.read(header, 0, 12, 0);
|
||||||
|
if (bytesRead < 12) throw new BadRequestException('文件过小,不是合法的 GLB/VRM');
|
||||||
|
|
||||||
|
if (header.readUInt32LE(0) !== GLB_MAGIC) {
|
||||||
|
// 可能是文本 glTF(.gltf),交由调用方按扩展名处理
|
||||||
|
return { isGlb: false, vrmVersion: null, generator: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkHeader = Buffer.alloc(8);
|
||||||
|
await handle.read(chunkHeader, 0, 8, 12);
|
||||||
|
const chunkLength = chunkHeader.readUInt32LE(0);
|
||||||
|
const chunkType = chunkHeader.readUInt32LE(4);
|
||||||
|
if (chunkType !== CHUNK_JSON) {
|
||||||
|
throw new BadRequestException('GLB 首个 chunk 不是 JSON,文件可能已损坏');
|
||||||
|
}
|
||||||
|
if (chunkLength <= 0 || chunkLength > 64 * 1024 * 1024) {
|
||||||
|
throw new BadRequestException('GLB JSON chunk 长度异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonBuf = Buffer.alloc(chunkLength);
|
||||||
|
await handle.read(jsonBuf, 0, chunkLength, 20);
|
||||||
|
|
||||||
|
let gltf: { extensions?: Record<string, unknown>; asset?: { generator?: string } };
|
||||||
|
try {
|
||||||
|
gltf = JSON.parse(jsonBuf.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('GLB JSON chunk 解析失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensions = gltf.extensions ?? {};
|
||||||
|
let vrmVersion: string | null = null;
|
||||||
|
if (extensions['VRMC_vrm']) {
|
||||||
|
const spec = extensions['VRMC_vrm'] as { specVersion?: string };
|
||||||
|
vrmVersion = spec?.specVersion ?? '1.0';
|
||||||
|
} else if (extensions['VRM']) {
|
||||||
|
const spec = extensions['VRM'] as { specVersion?: string; exporterVersion?: string };
|
||||||
|
vrmVersion = spec?.specVersion ?? spec?.exporterVersion ?? '0.x';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGlb: true,
|
||||||
|
vrmVersion,
|
||||||
|
generator: gltf.asset?.generator ?? null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { inspectVrm } from '../src/models/vrm-inspector';
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'opendance-vrm-'));
|
||||||
|
|
||||||
|
/** 构造合法 GLB:magic + version + length + JSON chunk */
|
||||||
|
function buildGlb(json: unknown): Buffer {
|
||||||
|
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
||||||
|
const pad = (4 - (jsonBuf.length % 4)) % 4;
|
||||||
|
const padded = Buffer.concat([jsonBuf, Buffer.alloc(pad, 0x20)]);
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
header.write('glTF', 0, 'ascii');
|
||||||
|
header.writeUInt32LE(2, 4);
|
||||||
|
header.writeUInt32LE(12 + 8 + padded.length, 8);
|
||||||
|
const chunkHeader = Buffer.alloc(8);
|
||||||
|
chunkHeader.writeUInt32LE(padded.length, 0);
|
||||||
|
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
|
||||||
|
return Buffer.concat([header, chunkHeader, padded]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(name: string, content: Buffer): string {
|
||||||
|
const path = join(dir, name);
|
||||||
|
writeFileSync(path, content);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VRM 版本识别', () => {
|
||||||
|
it('识别 VRM 1.0(VRMC_vrm 扩展)', async () => {
|
||||||
|
const path = write('v1.vrm', buildGlb({ extensions: { VRMC_vrm: { specVersion: '1.0' } } }));
|
||||||
|
const result = await inspectVrm(path);
|
||||||
|
expect(result.isGlb).toBe(true);
|
||||||
|
expect(result.vrmVersion).toBe('1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('识别 VRM 0.x(VRM 扩展)', async () => {
|
||||||
|
const path = write('v0.vrm', buildGlb({ extensions: { VRM: { specVersion: '0.0', exporterVersion: '0.79' } } }));
|
||||||
|
const result = await inspectVrm(path);
|
||||||
|
expect(result.vrmVersion).toBe('0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VRM 扩展缺失时版本为 null(普通 GLB)', async () => {
|
||||||
|
const path = write('plain.glb', buildGlb({ asset: { version: '2.0' }, meshes: [] }));
|
||||||
|
const result = await inspectVrm(path);
|
||||||
|
expect(result.isGlb).toBe(true);
|
||||||
|
expect(result.vrmVersion).toBeNull();
|
||||||
|
expect(result.generator).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文本 glTF 不按 GLB 处理', async () => {
|
||||||
|
const path = write('scene.gltf', Buffer.from('{"asset":{"version":"2.0"}}', 'utf8'));
|
||||||
|
const result = await inspectVrm(path);
|
||||||
|
expect(result.isGlb).toBe(false);
|
||||||
|
expect(result.vrmVersion).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件过小时报错', async () => {
|
||||||
|
const path = write('tiny.glb', Buffer.from('glTF', 'ascii'));
|
||||||
|
await expect(inspectVrm(path)).rejects.toThrow(/过小/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('首 chunk 非 JSON 时报错', async () => {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
header.write('glTF', 0, 'ascii');
|
||||||
|
header.writeUInt32LE(2, 4);
|
||||||
|
header.writeUInt32LE(32, 8);
|
||||||
|
const chunk = Buffer.alloc(8);
|
||||||
|
chunk.writeUInt32LE(4, 0);
|
||||||
|
chunk.writeUInt32LE(0x004e4942, 4); // 'BIN\0'
|
||||||
|
await expect(inspectVrm(write('bin-first.glb', Buffer.concat([header, chunk, Buffer.alloc(4)])))).rejects.toThrow(
|
||||||
|
/JSON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user