diff --git a/README.txt b/README.txt index a4717b5..b96caf3 100644 --- a/README.txt +++ b/README.txt @@ -294,6 +294,14 @@ deploy/.env 初始化引导(deploy/init-env.sh) 入库后**一律 status=pending(待审核)**:上传不再自动发布、不再自动设为默认, 必须经审核通过(模型审核面板可勾选「同时设为全局默认模型」)才对客户端可见。 +升级后回填已有模型(面数 / 骨骼 / 缩略图) + 升级到带 models.stats 的版本后,旧模型还没有分析与预览图。服务启动后执行一次: + cd /opt/opendance/web-server + node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 + # 只处理一个:--model-id 14 只列清单:--dry-run 全部重算:--force + 也可对单个模型调 POST /api/models/:id/reanalyze(只改 stats 与缩略图两个字段)。 + 若模型文件在存储中已丢失,接口返回 409 并提示先恢复文件,不会用空结果覆盖已有数据。 + 模型压缩包(.zip)自动搜索与转换 上传的 .zip 不再"只找 .pmx",而是**自动搜索包内可用模型**,走 "解压 → 选主模型 → 直接导入或转换 → 回读校验 → 入库",而不是把压缩包当模型存下来。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 2971303..66dc71b 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -398,6 +398,25 @@ paths: responses: '204': { description: 已删除 } + /api/models/{id}/reanalyze: + parameters: [{ $ref: '#/components/parameters/Id' }] + post: + tags: [models] + summary: 重新分析模型并重建缩略图(升级回填 / 文件替换后刷新) + description: | + 重新计算 `stats`(面数、骨骼树、humanoid 覆盖、可驱动性)并重出缩略图, + 只改这两个字段。用于:升级到带 `stats` 的版本后回填历史模型、 + 模型文件被替换后刷新、缩略图渲染器升级后重出图。 + 批量回填用 `node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000`。 + responses: + '200': + description: 重新分析后的模型 + content: + application/json: + schema: { $ref: '#/components/schemas/Model' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: 模型没有文件资产,无法分析 } + /api/models/{id}/primary: parameters: [{ $ref: '#/components/parameters/Id' }] post: diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 6a77070..6c73f32 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -118,6 +118,7 @@ for required in \ "opendance-web-v${VERSION}/web-server/dist/preprocess/mmd-skeleton.js" \ "opendance-web-v${VERSION}/web-server/scripts/inspect-vmd.mjs" \ "opendance-web-v${VERSION}/web-server/scripts/import-default-model.mjs" \ + "opendance-web-v${VERSION}/web-server/scripts/reanalyze-models.mjs" \ "opendance-web-v${VERSION}/web-server/dist/common/zip-reader.js" \ "opendance-web-v${VERSION}/web-server/dist/models/model-convert.js" \ "opendance-web-v${VERSION}/web-server/dist/models/glb-textures.js" \ diff --git a/tasks/005-model-upload.md b/tasks/005-model-upload.md index f47b5e5..518670f 100644 --- a/tasks/005-model-upload.md +++ b/tasks/005-model-upload.md @@ -186,3 +186,20 @@ npm run test:smoke # 冒烟 [4] 段落 `test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根 humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。 `test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。 + +## 追加:历史模型回填(`POST /api/models/:id/reanalyze`) + +升级到带 `models.stats` 的版本后,已有模型还没有分析与缩略图。重新分析接口只改 +`stats` 与 `thumbnailAssetId` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。 + +```bash +cd /opt/opendance/web-server +node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 回填全部缺 stats 的 +node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列清单 +``` + +实测(真实 21MB VRM,290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 / +humanoid 7/22,并重建 512×512 缩略图。 +文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。 +另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**, +避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。 diff --git a/web-server/scripts/reanalyze-models.mjs b/web-server/scripts/reanalyze-models.mjs new file mode 100644 index 0000000..f65a4f6 --- /dev/null +++ b/web-server/scripts/reanalyze-models.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * 回填 / 刷新已有模型的分析结果与缩略图。 + * + * 用途:升级到带 `models.stats` 的版本后,历史模型还没有面数、骨骼树与预览图; + * 这个脚本逐个调用 `POST /api/models/{id}/reanalyze` 把它们补齐。 + * + * 用法(在 web-server 目录下,服务需已启动): + * node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 全部模型 + * node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --model-id 14 + * node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --force # 已有 stats 的也重算 + * node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列出会处理谁 + * + * 只读列表 + 调 reanalyze 接口,不直接改数据库。 + */ +const args = process.argv.slice(2); +const flag = (name, fallback = undefined) => { + const index = args.indexOf(name); + return index >= 0 && args[index + 1] && !args[index + 1].startsWith('--') + ? args[index + 1] + : index >= 0 + ? true + : fallback; +}; +const has = (name) => args.includes(name); + +const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, ''); +const onlyId = Number(flag('--model-id', 0)) || 0; +const force = has('--force'); +const dryRun = has('--dry-run'); +const pageSize = 50; + +async function api(path, options = {}) { + const response = await fetch(`${url}${path}`, options); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + if (!response.ok) { + throw new Error(`${options.method ?? 'GET'} ${path} → HTTP ${response.status}:${JSON.stringify(body)}`); + } + return body; +} + +/** 拉取全部模型(分页) */ +async function listAllModels() { + const all = []; + for (let page = 1; ; page += 1) { + const data = await api(`/api/models?page=${page}&pageSize=${pageSize}`); + all.push(...(data.items ?? [])); + if (all.length >= (data.total ?? 0) || (data.items ?? []).length === 0) break; + } + return all; +} + +async function main() { + if (onlyId) { + const model = await api(`/api/models/${onlyId}`); + await reanalyze(model); + return; + } + + const models = await listAllModels(); + const todo = force ? models : models.filter((m) => !m.stats); + console.log( + `模型总数 ${models.length},${force ? '(--force)全部重算' : '需要回填'} ${todo.length} 个`, + ); + if (dryRun) { + for (const m of todo) console.log(` #${m.id} ${m.name}(${m.format})`); + return; + } + + let ok = 0; + let failed = 0; + for (const model of todo) { + try { + await reanalyze(model); + ok += 1; + } catch (error) { + failed += 1; + console.error(` ✗ #${model.id} ${model.name}:${error instanceof Error ? error.message : error}`); + } + } + console.log(`完成:成功 ${ok} 个,失败 ${failed} 个`); + if (failed > 0) process.exit(1); +} + +async function reanalyze(model) { + const result = await api(`/api/models/${model.id}/reanalyze`, { method: 'POST' }); + const stats = result.stats ?? {}; + console.log( + ` ✓ #${result.id} ${result.name}:面数=${stats.triangles ?? '—'} 骨骼=${stats.bones ?? '—'} ` + + `humanoid=${stats.humanoidMapped ?? '—'}/${stats.humanoidTotal ?? '—'} ` + + `可驱动=${stats.drivable ?? '—'} 缩略图=${result.thumbnailUrl ? '已生成' : '无'}`, + ); +} + +main().catch((error) => { + console.error(`失败:${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/web-server/src/models/models.controller.ts b/web-server/src/models/models.controller.ts index c84bb9a..8c0187c 100644 --- a/web-server/src/models/models.controller.ts +++ b/web-server/src/models/models.controller.ts @@ -39,6 +39,13 @@ export class ModelsController { return this.models.create(body, file); } + /** 重新分析并重建缩略图(升级回填 / 文件替换后刷新) */ + @Post(':id/reanalyze') + @HttpCode(HttpStatus.OK) + reanalyze(@Param('id', ParseIntPipe) id: number): Promise { + return this.models.reanalyze(id); + } + @Get(':id') detail(@Param('id', ParseIntPipe) id: number): Promise { return this.models.findOne(id); diff --git a/web-server/src/models/models.service.ts b/web-server/src/models/models.service.ts index c4d2851..0dec914 100644 --- a/web-server/src/models/models.service.ts +++ b/web-server/src/models/models.service.ts @@ -7,6 +7,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { extname } from 'node:path'; import { DataSource, Repository } from 'typeorm'; @@ -212,6 +213,44 @@ export class ModelsService { return dto; } + /** + * 重新分析已有模型并重建缩略图(T-WEB-005 追加)。 + * + * 用途:① 升级到带 `stats` 的版本后**回填历史模型**;② 模型文件被替换后刷新; + * ③ 缩略图渲染器升级后重出图。只改 `stats` / `thumbnailAssetId`,不动其它字段。 + */ + async reanalyze(id: number): Promise { + const model = await this.getEntity(id); + if (!model.fileAssetId) { + throw new ConflictException(`模型 #${id} 没有模型文件,无法分析`); + } + const asset = await this.storage.findById(model.fileAssetId); + if (!asset) throw new NotFoundException(`模型 #${id} 的文件资产已丢失`); + + const absolutePath = this.storage.absolutePath(asset); + if (!existsSync(absolutePath)) { + throw new ConflictException( + `模型 #${id} 的文件在存储中不存在(${absolutePath});` + + '请先恢复文件(或重新上传该模型)再重新分析,避免用空结果覆盖已有分析数据', + ); + } + const stats = await this.analyzeSafely(absolutePath, asset.size, model.name); + const thumbnailAssetId = await this.thumbnailSafely(absolutePath, model.name); + + model.stats = stats as unknown as Record; + if (thumbnailAssetId !== null) model.thumbnailAssetId = thumbnailAssetId; + if (stats.vrmVersion) model.vrmVersion = stats.vrmVersion; + + const saved = await this.models.save(model); + this.logger.log( + `模型 #${id} 重新分析完成:面数=${stats.triangles} 骨骼=${stats.bones} ` + + `humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}` + + ` 缩略图=${thumbnailAssetId === null ? '未生成' : '已更新'}`, + ); + const [dto] = await this.toDtos([saved]); + return dto; + } + /** 分析模型;任何异常都降级为一条 warning,不阻断上传 */ private async analyzeSafely( absolutePath: string, diff --git a/web-server/src/storage/storage.service.ts b/web-server/src/storage/storage.service.ts index f78cf7d..39bb483 100644 --- a/web-server/src/storage/storage.service.ts +++ b/web-server/src/storage/storage.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; +import { createReadStream, existsSync } from 'node:fs'; import { copyFile, mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises'; -import { extname, join, resolve, sep } from 'node:path'; +import { dirname, extname, join, resolve, sep } from 'node:path'; import { In, Repository } from 'typeorm'; import { AssetKind } from '../common/enums'; import { AppConfig } from '../config/configuration'; @@ -161,15 +161,26 @@ export class StorageService { mime: string, ): Promise { const sha256 = createHash('sha256').update(content).digest('hex'); + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); + const existing = await this.assets.findOne({ where: { sha256 } }); - if (existing) return { asset: existing, url: this.url(existing) as string }; + if (existing) { + // 去重命中,但要确认文件真的还在:换过 STORAGE_DIR、或只恢复了数据库没恢复 + // 文件时,旧记录会指向一个不存在的路径。此时**按原路径补写一份**, + // 否则后续的模型分析 / 缩略图 / 下载都会静默失败(曾实测到"全 0 分析结果")。 + const absolute = this.absolutePath(existing); + if (!existsSync(absolute)) { + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, buf); + } + return { asset: existing, url: this.url(existing) as string }; + } const ext = extname(originalName).toLowerCase(); const now = new Date(); const relDir = join(kind, String(now.getUTCFullYear()), String(now.getUTCMonth() + 1).padStart(2, '0'), sha256.slice(0, 2)); const relPath = join(relDir, `${sha256}${ext}`); await mkdir(join(this.root, relDir), { recursive: true }); - const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); await writeFile(join(this.root, relPath), buf); const entity = this.assets.create({