fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)
实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464 字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。 因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。 - default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的 sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录) - models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 → 名称 匹配,都落空才回落到 VRM 优先规则并告警 - ModelDto 增加 sourceSha256(非压缩包上传为 null) - scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析 (文件当第一个参数时识别不出,会打印用法) - 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论; README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正 另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系; PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。 验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重 导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
This commit is contained in:
@@ -7,7 +7,7 @@ import { DefaultModelSeed } from '../config/default-model';
|
||||
* 把默认模型写进源码(`web-server/src/config/default-model.ts`)。
|
||||
*
|
||||
* 需求原文是"转换成功则把这个模型设置为默认模型写进源码中",因此这里不是只改数据库,
|
||||
* 而是把种子(名称 / 格式 / 产物 sha256)落进源码文件,随代码一起提交、一起部署。
|
||||
* 而是把种子(名称 / 格式 / **上传压缩包的 sha256**)落进源码文件,随代码一起提交、一起部署。
|
||||
*
|
||||
* 注意:生产环境 systemd 单元带 `ProtectSystem=strict`,服务进程**没有**程序目录的写权限,
|
||||
* 因此写入通常由 `scripts/import-default-model.mjs`(在服务沙箱之外、以 root 运行)完成;
|
||||
@@ -27,8 +27,9 @@ export function renderDefaultModelSource(seed: DefaultModelSeed): string {
|
||||
* 运行时只读:\`ModelsService.resolveDefaultModelId()\` 会先看这里,
|
||||
* 再回落到契约 \`data-models.md\` §4.1 的 VRM 优先规则。
|
||||
*
|
||||
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
|
||||
* 用转换产物的 sha256 才能稳定指回同一个模型。
|
||||
* 为什么记上传压缩包的 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变;
|
||||
* 而**转换产物的 sha256 每次转换都不一样**(Assimp 的 GLB 导出非字节级确定),
|
||||
* 只有用户上传的那份压缩包才是稳定标识。
|
||||
*
|
||||
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
|
||||
*/
|
||||
@@ -40,8 +41,17 @@ export interface DefaultModelSeed {
|
||||
name: string;
|
||||
/** 模型格式:fbx / glb / vrm */
|
||||
format: string;
|
||||
/** 转换产物文件的 sha256(定位默认模型的依据) */
|
||||
sha256: string;
|
||||
/**
|
||||
* 定位默认模型的主依据:**上传压缩包的 sha256**(用户文件,永远稳定)。
|
||||
* 注意不能用转换产物的 sha256:实测 Assimp 的 GLB 导出不是字节级确定的,
|
||||
* 同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 里有十几字节浮动)。
|
||||
*/
|
||||
sourceSha256: string;
|
||||
/**
|
||||
* 转换产物文件的 sha256 —— **仅作记录,不用于匹配**(每次转换都会变)。
|
||||
* 直接上传模型文件(非压缩包)时,它同时就是 sourceSha256。
|
||||
*/
|
||||
fileSha256: string;
|
||||
/** 转换来源说明,例如 \`Hatsune Miku.pmx → GLB\`;非转换模型为 null */
|
||||
convertedFrom: string | null;
|
||||
/** 最近一次写入时间(ISO 8601) */
|
||||
@@ -53,14 +63,15 @@ 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)},
|
||||
sourceSha256: ${quote(seed.sourceSha256)},
|
||||
fileSha256: ${quote(seed.fileSha256)},
|
||||
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;
|
||||
return seed.sourceSha256.trim().length > 0 || seed.fileSha256.trim().length > 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ export interface ModelDto {
|
||||
isPrimary: boolean;
|
||||
fileUrl: string | null;
|
||||
fileSize: number | null;
|
||||
/** 模型文件(转换产物)的 sha256,用于换库后定位默认模型 */
|
||||
/** 模型文件(转换产物)的 sha256 —— 每次转换都会变,仅作记录 */
|
||||
fileSha256: string | null;
|
||||
/** 上传压缩包的 sha256(稳定);非压缩包上传为 null,此时以 fileSha256 为准 */
|
||||
sourceSha256: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
/** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */
|
||||
conversionNote: string | null;
|
||||
@@ -30,6 +32,7 @@ export interface ModelUrlLookup {
|
||||
thumbnailUrl: string | null;
|
||||
fileSize?: number | null;
|
||||
fileSha256?: string | null;
|
||||
sourceSha256?: string | null;
|
||||
}
|
||||
|
||||
export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto {
|
||||
@@ -42,6 +45,7 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt
|
||||
fileUrl: urls.fileUrl,
|
||||
fileSize: urls.fileSize ?? null,
|
||||
fileSha256: urls.fileSha256 ?? null,
|
||||
sourceSha256: urls.sourceSha256 ?? null,
|
||||
thumbnailUrl: urls.thumbnailUrl,
|
||||
conversionNote: model.conversionNote ?? null,
|
||||
sourceType: model.sourceType,
|
||||
|
||||
@@ -227,7 +227,11 @@ export class ModelsService {
|
||||
);
|
||||
|
||||
if (autoDefault) {
|
||||
await this.applyDefaultModel(saved.id, stored.asset.sha256, converted.note);
|
||||
await this.applyDefaultModel(saved.id, {
|
||||
sourceSha256: archiveAsset.asset.sha256,
|
||||
fileSha256: stored.asset.sha256,
|
||||
note: converted.note,
|
||||
});
|
||||
// setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确
|
||||
const [dto] = await this.toDtos([await this.getEntity(saved.id)]);
|
||||
return dto;
|
||||
@@ -242,15 +246,19 @@ export class ModelsService {
|
||||
* `ProtectSystem=strict` 不允许服务进程改程序目录,通常由
|
||||
* `scripts/import-default-model.mjs` 在沙箱外完成写入。
|
||||
*/
|
||||
private async applyDefaultModel(id: number, sha256: string, note: string): Promise<void> {
|
||||
private async applyDefaultModel(
|
||||
id: number,
|
||||
payload: { sourceSha256: string; fileSha256: 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,
|
||||
sourceSha256: payload.sourceSha256,
|
||||
fileSha256: payload.fileSha256,
|
||||
convertedFrom: payload.note.split(',')[0] ?? null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (result.written) {
|
||||
@@ -335,14 +343,42 @@ export class ModelsService {
|
||||
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;
|
||||
// 主依据是"上传压缩包的 sha256"(稳定);转换产物的 sha256 每次都会变,
|
||||
// 只在没有压缩包(直接上传模型文件)时才拿它当依据;最后再兜底按名字匹配。
|
||||
const sourceSha = DEFAULT_MODEL_SEED.sourceSha256.trim();
|
||||
const fileSha = DEFAULT_MODEL_SEED.fileSha256.trim();
|
||||
if (sourceSha || fileSha) {
|
||||
const assets = await this.storage.findMany(
|
||||
published.flatMap((m) => [m.sourceAssetId, m.fileAssetId]),
|
||||
);
|
||||
const match = (wantSource: boolean): number | null => {
|
||||
const wanted = wantSource ? sourceSha : fileSha;
|
||||
if (!wanted) return null;
|
||||
const hit = published.find((m) => {
|
||||
const asset = wantSource
|
||||
? m.sourceAssetId === null
|
||||
? null
|
||||
: assets.get(m.sourceAssetId)
|
||||
: m.fileAssetId === null
|
||||
? null
|
||||
: assets.get(m.fileAssetId);
|
||||
return asset?.sha256 === wanted;
|
||||
});
|
||||
return hit ? hit.id : null;
|
||||
};
|
||||
const bySource = match(true);
|
||||
if (bySource !== null) return bySource;
|
||||
const byFile = match(false);
|
||||
if (byFile !== null) return byFile;
|
||||
|
||||
const seedName = DEFAULT_MODEL_SEED.name.trim();
|
||||
if (seedName) {
|
||||
const byName = published.find((m) => m.name === seedName);
|
||||
if (byName) {
|
||||
this.logger.warn('默认模型种子按 sha256 未匹配到,已按名称匹配');
|
||||
return byName.id;
|
||||
}
|
||||
}
|
||||
this.logger.warn('源码里的默认模型种子在内库中找不到对应模型,回落到 VRM 优先规则');
|
||||
}
|
||||
|
||||
@@ -365,16 +401,18 @@ export class ModelsService {
|
||||
|
||||
async toDtos(rows: CharacterModel[]): Promise<ModelDto[]> {
|
||||
const assets = await this.storage.findMany(
|
||||
rows.flatMap((row) => [row.fileAssetId, row.thumbnailAssetId]),
|
||||
rows.flatMap((row) => [row.fileAssetId, row.thumbnailAssetId, row.sourceAssetId]),
|
||||
);
|
||||
return rows.map((row) => {
|
||||
const file = row.fileAssetId ? assets.get(row.fileAssetId) : undefined;
|
||||
const thumb = row.thumbnailAssetId ? assets.get(row.thumbnailAssetId) : undefined;
|
||||
const source = row.sourceAssetId ? assets.get(row.sourceAssetId) : undefined;
|
||||
return toModelDto(row, {
|
||||
fileUrl: file ? this.storage.url(file) : null,
|
||||
thumbnailUrl: thumb ? this.storage.url(thumb) : null,
|
||||
fileSize: file?.size ?? null,
|
||||
fileSha256: file?.sha256 ?? null,
|
||||
sourceSha256: source?.sha256 ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user